Skip to content

Added gift subscription refund handling via Stripe charge.refunded webhook#27321

Open
mike182uk wants to merge 1 commit intomainfrom
BER-3474-handle-gift-subscription-refund
Open

Added gift subscription refund handling via Stripe charge.refunded webhook#27321
mike182uk wants to merge 1 commit intomainfrom
BER-3474-handle-gift-subscription-refund

Conversation

@mike182uk
Copy link
Copy Markdown
Member

ref https://linear.app/ghost/issue/BER-3474

Added gift subscription refund handling via Stripe charge.refunded webhook that marks the gift as refunded

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Walkthrough

This PR adds refund functionality for gift subscriptions by extending the gift persistence layer with an update() method and payment intent lookup, implementing a markRefunded() state transition on the Gift entity, and introducing a new webhook event service to handle Stripe charge.refunded events. The refund workflow extracts the payment intent from the incoming Stripe webhook, locates the corresponding gift record, transitions it to refunded status, persists the changes, and includes handling for non-gift charges (subscription refunds). Comprehensive unit and end-to-end tests validate the refund behavior across various scenarios.

Possibly related PRs

Suggested reviewers

  • sagzy
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: adding gift subscription refund handling via the Stripe charge.refunded webhook, which is the primary objective implemented across all modified files.
Description check ✅ Passed The PR description is directly related to the changeset, explaining that it adds gift subscription refund handling via the Stripe charge.refunded webhook and marks gifts as refunded, which aligns with the implemented changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch BER-3474-handle-gift-subscription-refund

Comment @coderabbitai help to get the list of available commands and usage tips.

@mike182uk mike182uk force-pushed the BER-3474-handle-gift-subscription-refund branch from 124b8e7 to 0b68cc7 Compare April 9, 2026 20:29
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
ghost/core/test/unit/server/services/gifts/gift.test.ts (1)

137-160: Deduplicate buildGift helper to reduce test maintenance cost.

This helper is functionally identical to the one already defined in the same file; extracting one shared helper will keep fixture changes in one place.

♻️ Proposed refactor
 describe('Gift', function () {
+    function buildGift(overrides: Partial<ConstructorParameters<typeof Gift>[0]> = {}) {
+        return new Gift({
+            token: 'gift-token',
+            buyerEmail: 'buyer@example.com',
+            buyerMemberId: 'buyer_member_1',
+            redeemerMemberId: null,
+            tierId: 'tier_1',
+            cadence: 'year',
+            duration: 1,
+            currency: 'usd',
+            amount: 5000,
+            stripeCheckoutSessionId: 'cs_123',
+            stripePaymentIntentId: 'pi_456',
+            consumesAt: null,
+            expiresAt: new Date('2030-01-01T00:00:00.000Z'),
+            status: 'purchased',
+            purchasedAt: new Date('2026-01-01T00:00:00.000Z'),
+            redeemedAt: null,
+            consumedAt: null,
+            expiredAt: null,
+            refundedAt: null,
+            ...overrides
+        });
+    }
+
     describe('redeemability', function () {
-        function buildGift(...) { ... }
         ...
     });

     describe('markRefunded', function () {
-        function buildGift(...) { ... }
         ...
     });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/unit/server/services/gifts/gift.test.ts` around lines 137 -
160, There are two identical buildGift helpers; remove this duplicate and reuse
the existing buildGift to avoid divergence—keep one canonical buildGift
(signature: function buildGift(overrides: Partial<ConstructorParameters<typeof
Gift>[0]> = {}) { ... }) and in the other location delete the duplicate and
reference the single helper instead (or move it to a common/top-level spot in
the test file), ensuring the overrides typing and default fields for Gift
(token, buyerEmail, buyerMemberId, tierId, cadence, duration, currency, amount,
stripeCheckoutSessionId, stripePaymentIntentId, consumesAt, expiresAt, status,
purchasedAt, etc.) remain intact.
ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts (1)

88-108: Extract a shared Gift→row mapper to avoid drift between create and update.

The field mapping is duplicated and easy to desynchronize during future schema changes.

♻️ Suggested refactor
+    private toRow(gift: Gift): Partial<GiftRow> {
+        return {
+            token: gift.token,
+            buyer_email: gift.buyerEmail,
+            buyer_member_id: gift.buyerMemberId,
+            redeemer_member_id: gift.redeemerMemberId,
+            tier_id: gift.tierId,
+            cadence: gift.cadence,
+            duration: gift.duration,
+            currency: gift.currency,
+            amount: gift.amount,
+            stripe_checkout_session_id: gift.stripeCheckoutSessionId,
+            stripe_payment_intent_id: gift.stripePaymentIntentId,
+            consumes_at: gift.consumesAt,
+            expires_at: gift.expiresAt,
+            status: gift.status,
+            purchased_at: gift.purchasedAt,
+            redeemed_at: gift.redeemedAt,
+            consumed_at: gift.consumedAt,
+            expired_at: gift.expiredAt,
+            refunded_at: gift.refundedAt
+        };
+    }
+
     async create(gift: Gift) {
-        await this.model.add({
-            ...
-        });
+        await this.model.add(this.toRow(gift));
     }

     async update(gift: Gift): Promise<void> {
         const existing = await this.model.findOne({token: gift.token}, {require: false});
         if (!existing) {
             throw new errors.NotFoundError({message: `Gift not found: ${gift.token}`});
         }
         const id = existing.toJSON().id;
-        await this.model.edit({
-            ...
-        }, {id});
+        await this.model.edit(this.toRow(gift), {id});
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts` around
lines 88 - 108, Extract the duplicated field mapping into a single shared mapper
function (e.g. mapGiftToRow) used by both create and update paths so create and
update cannot drift; move the object with token, buyer_email, buyer_member_id,
redeemer_member_id, tier_id, cadence, duration, currency, amount,
stripe_checkout_session_id, stripe_payment_intent_id, consumes_at, expires_at,
status, purchased_at, redeemed_at, consumed_at, expired_at, refunded_at (and the
buyerMemberId/redeemerMemberId/stripe* keys) into that helper and call it from
the code that invokes this.model.edit(...) and the create method in
gift-bookshelf-repository.ts so both use the same mapping logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/services/gifts/gift-service.ts`:
- Around line 185-186: When a gift refund is processed in gift-service.ts, add a
reconciliation path that detects if the gift has been redeemed/consumed and, if
so, revokes the granted entitlement from the recipient member and downgrades
them back to free; locate the refund handler (e.g., the method that marks a gift
refunded or processesRefund/refundGift) and after marking the gift refunded call
the members/subscription API (e.g., MemberService.revokePaidAccess or
SubscriptionService.downgradeMemberToFree) to remove the paid access, persist
the change, and emit the appropriate events/audit log; add unit tests and an e2e
test that simulates a redeemed gift being refunded and asserts the member loses
paid access and billing/subscription state is consistent.

---

Nitpick comments:
In `@ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts`:
- Around line 88-108: Extract the duplicated field mapping into a single shared
mapper function (e.g. mapGiftToRow) used by both create and update paths so
create and update cannot drift; move the object with token, buyer_email,
buyer_member_id, redeemer_member_id, tier_id, cadence, duration, currency,
amount, stripe_checkout_session_id, stripe_payment_intent_id, consumes_at,
expires_at, status, purchased_at, redeemed_at, consumed_at, expired_at,
refunded_at (and the buyerMemberId/redeemerMemberId/stripe* keys) into that
helper and call it from the code that invokes this.model.edit(...) and the
create method in gift-bookshelf-repository.ts so both use the same mapping
logic.

In `@ghost/core/test/unit/server/services/gifts/gift.test.ts`:
- Around line 137-160: There are two identical buildGift helpers; remove this
duplicate and reuse the existing buildGift to avoid divergence—keep one
canonical buildGift (signature: function buildGift(overrides:
Partial<ConstructorParameters<typeof Gift>[0]> = {}) { ... }) and in the other
location delete the duplicate and reference the single helper instead (or move
it to a common/top-level spot in the test file), ensuring the overrides typing
and default fields for Gift (token, buyerEmail, buyerMemberId, tierId, cadence,
duration, currency, amount, stripeCheckoutSessionId, stripePaymentIntentId,
consumesAt, expiresAt, status, purchasedAt, etc.) remain intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d79bba21-5aea-45b8-8f40-feb293aa8281

📥 Commits

Reviewing files that changed from the base of the PR and between 37cd0df and 0b68cc7.

📒 Files selected for processing (14)
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js
  • ghost/core/core/server/services/stripe/stripe-service.js
  • ghost/core/core/server/services/stripe/webhook-controller.js
  • ghost/core/core/server/services/stripe/webhook-manager.js
  • ghost/core/test/e2e-api/members/gifts.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js
  • ghost/core/test/unit/server/services/stripe/webhook-controller.test.js

Comment on lines +185 to +186
// TODO: if the gift was already redeemed/consumed, we should also
// downgrade the recipient member back to free.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refunded redeemed gifts can keep paid access (missing downgrade path).

Line 185 leaves a functional gap: if a gift was already redeemed/consumed, refunding only the gift record can leave member entitlement inconsistent with billing state. Please implement the downgrade/reconciliation path (with tests) before release.

I can help draft the downgrade flow and the corresponding unit/e2e test cases if you want me to open a follow-up issue outline.

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 185-185: Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1z8BW7-rAd27ODl7tT&open=AZ1z8BW7-rAd27ODl7tT&pullRequest=27321

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift-service.ts` around lines 185 -
186, When a gift refund is processed in gift-service.ts, add a reconciliation
path that detects if the gift has been redeemed/consumed and, if so, revokes the
granted entitlement from the recipient member and downgrades them back to free;
locate the refund handler (e.g., the method that marks a gift refunded or
processesRefund/refundGift) and after marking the gift refunded call the
members/subscription API (e.g., MemberService.revokePaidAccess or
SubscriptionService.downgradeMemberToFree) to remove the paid access, persist
the change, and emit the appropriate events/audit log; add unit tests and an e2e
test that simulates a redeemed gift being refunded and asserts the member loses
paid access and billing/subscription state is consistent.

…webhook

ref https://linear.app/ghost/issue/BER-3474

Added gift subscription refund handling via Stripe `charge.refunded` webhook
that marks the gift as refunded
@mike182uk mike182uk force-pushed the BER-3474-handle-gift-subscription-refund branch from 0b68cc7 to 603df28 Compare April 9, 2026 21:04
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 9, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
4.1% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/members/gift-subscriptions.test.js (1)

346-380: Make the ignore-path tests assert the no-op.

These two tests currently only prove that sendWebhook() resolves. They won't catch a regression where the handler still returns 200 but incorrectly creates or mutates a gift. Please add an explicit DB assertion that no matching gift exists, or that a control gift remains unchanged after the webhook.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/e2e-api/members/gift-subscriptions.test.js` around lines 346
- 380, The tests currently only call stripeMocker.sendWebhook and
DomainEvents.allSettled but don't assert the webhook was a true no-op; update
each test to query the gifts table (e.g., via the same Gift model/repository
used elsewhere in tests) after DomainEvents.allSettled and assert that no gift
exists with payment_intent 'pi_non_gift_charge' and 'pi_sub_charge'
respectively, or alternatively create a control gift before the webhook and
assert its fields remain unchanged after the webhook; use the existing
stripeMocker.sendWebhook and DomainEvents.allSettled calls and the project's
Gift model/repository to perform the DB assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/services/gifts/gift.ts`:
- Around line 144-152: markRefunded() currently only sets status and refundedAt
which can leave redeemedAt/consumedAt/expiredAt set and cause checkRedeemable()
to still return 'redeemed'/'consumed'/'expired'; update markRefunded() to make
the refund transition exclusive by clearing other terminal timestamps (e.g.,
redeemedAt, consumedAt, expiredAt) and any related flags when setting status =
'refunded' and refundedAt, and/or modify checkRedeemable() to give isRefunded()
precedence (evaluate refunded before redeemed/consumed/expired) so refunded
state always wins.

---

Nitpick comments:
In `@ghost/core/test/e2e-api/members/gift-subscriptions.test.js`:
- Around line 346-380: The tests currently only call stripeMocker.sendWebhook
and DomainEvents.allSettled but don't assert the webhook was a true no-op;
update each test to query the gifts table (e.g., via the same Gift
model/repository used elsewhere in tests) after DomainEvents.allSettled and
assert that no gift exists with payment_intent 'pi_non_gift_charge' and
'pi_sub_charge' respectively, or alternatively create a control gift before the
webhook and assert its fields remain unchanged after the webhook; use the
existing stripeMocker.sendWebhook and DomainEvents.allSettled calls and the
project's Gift model/repository to perform the DB assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2ab977b-71f9-4710-b8e2-8136b9cbae87

📥 Commits

Reviewing files that changed from the base of the PR and between 0b68cc7 and 603df28.

📒 Files selected for processing (15)
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift.ts
  • ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js
  • ghost/core/core/server/services/stripe/stripe-service.js
  • ghost/core/core/server/services/stripe/webhook-controller.js
  • ghost/core/core/server/services/stripe/webhook-manager.js
  • ghost/core/test/e2e-api/members/gift-subscriptions.test.js
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/gifts/utils.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js
  • ghost/core/test/unit/server/services/stripe/webhook-controller.test.js
✅ Files skipped from review due to trivial changes (2)
  • ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts
  • ghost/core/core/server/services/gifts/gift-repository.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • ghost/core/core/server/services/stripe/webhook-manager.js
  • ghost/core/test/unit/server/services/gifts/gift.test.ts
  • ghost/core/test/unit/server/services/gifts/gift-service.test.ts
  • ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js
  • ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js
  • ghost/core/core/server/services/gifts/gift-service.ts
  • ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts

Comment on lines +144 to +152
markRefunded(): boolean {
if (this.isRefunded()) {
return false;
}

this.status = 'refunded';
this.refundedAt = new Date();

return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refunds can leave the gift in conflicting terminal states.

markRefunded() only updates status/refundedAt. If a charge is refunded after the gift was already redeemed, consumed, or expired, those timestamps remain set, and checkRedeemable() will still return 'redeemed', 'consumed', or 'expired' because those checks run before isRefunded(). Please make the refund transition exclusive, or give refunded precedence when evaluating redeemability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/gifts/gift.ts` around lines 144 - 152,
markRefunded() currently only sets status and refundedAt which can leave
redeemedAt/consumedAt/expiredAt set and cause checkRedeemable() to still return
'redeemed'/'consumed'/'expired'; update markRefunded() to make the refund
transition exclusive by clearing other terminal timestamps (e.g., redeemedAt,
consumedAt, expiredAt) and any related flags when setting status = 'refunded'
and refundedAt, and/or modify checkRedeemable() to give isRefunded() precedence
(evaluate refunded before redeemed/consumed/expired) so refunded state always
wins.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant