Added gift subscription refund handling via Stripe charge.refunded webhook#27321
Added gift subscription refund handling via Stripe charge.refunded webhook#27321
charge.refunded webhook#27321Conversation
WalkthroughThis PR adds refund functionality for gift subscriptions by extending the gift persistence layer with an Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
124b8e7 to
0b68cc7
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
ghost/core/test/unit/server/services/gifts/gift.test.ts (1)
137-160: DeduplicatebuildGifthelper 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 betweencreateandupdate.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
📒 Files selected for processing (14)
ghost/core/core/server/services/gifts/gift-bookshelf-repository.tsghost/core/core/server/services/gifts/gift-repository.tsghost/core/core/server/services/gifts/gift-service.tsghost/core/core/server/services/gifts/gift.tsghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.jsghost/core/core/server/services/stripe/stripe-service.jsghost/core/core/server/services/stripe/webhook-controller.jsghost/core/core/server/services/stripe/webhook-manager.jsghost/core/test/e2e-api/members/gifts.test.jsghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.tsghost/core/test/unit/server/services/gifts/gift-service.test.tsghost/core/test/unit/server/services/gifts/gift.test.tsghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.jsghost/core/test/unit/server/services/stripe/webhook-controller.test.js
| // TODO: if the gift was already redeemed/consumed, we should also | ||
| // downgrade the recipient member back to free. |
There was a problem hiding this comment.
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.
🤖 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
0b68cc7 to
603df28
Compare
|
There was a problem hiding this comment.
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 returns200but 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
📒 Files selected for processing (15)
ghost/core/core/server/services/gifts/gift-bookshelf-repository.tsghost/core/core/server/services/gifts/gift-repository.tsghost/core/core/server/services/gifts/gift-service.tsghost/core/core/server/services/gifts/gift.tsghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.jsghost/core/core/server/services/stripe/stripe-service.jsghost/core/core/server/services/stripe/webhook-controller.jsghost/core/core/server/services/stripe/webhook-manager.jsghost/core/test/e2e-api/members/gift-subscriptions.test.jsghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.tsghost/core/test/unit/server/services/gifts/gift-service.test.tsghost/core/test/unit/server/services/gifts/gift.test.tsghost/core/test/unit/server/services/gifts/utils.tsghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.jsghost/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
| markRefunded(): boolean { | ||
| if (this.isRefunded()) { | ||
| return false; | ||
| } | ||
|
|
||
| this.status = 'refunded'; | ||
| this.refundedAt = new Date(); | ||
|
|
||
| return true; |
There was a problem hiding this comment.
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.


ref https://linear.app/ghost/issue/BER-3474
Added gift subscription refund handling via Stripe
charge.refundedwebhook that marks the gift as refunded