From 603df28e0fcad9eb840e4d271e797e6f3b8751fb Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Thu, 9 Apr 2026 21:25:08 +0100 Subject: [PATCH] Added gift subscription refund handling via Stripe `charge.refunded` 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 --- .../gifts/gift-bookshelf-repository.ts | 47 +++++++- .../server/services/gifts/gift-repository.ts | 2 + .../server/services/gifts/gift-service.ts | 21 ++++ ghost/core/core/server/services/gifts/gift.ts | 11 ++ .../webhook/charge-refunded-event-service.js | 59 +++++++++ .../server/services/stripe/stripe-service.js | 10 +- .../services/stripe/webhook-controller.js | 14 ++- .../server/services/stripe/webhook-manager.js | 3 +- .../members/gift-subscriptions.test.js | 114 ++++++++++++++++-- .../gifts/gift-bookshelf-repository.test.ts | 2 + .../services/gifts/gift-service.test.ts | 71 +++++++---- .../unit/server/services/gifts/gift.test.ts | 55 +++++---- .../test/unit/server/services/gifts/utils.ts | 26 ++++ .../charge-refunded-event-service.test.js | 66 ++++++++++ .../stripe/webhook-controller.test.js | 19 +++ 15 files changed, 455 insertions(+), 65 deletions(-) create mode 100644 ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js create mode 100644 ghost/core/test/unit/server/services/gifts/utils.ts create mode 100644 ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts index f262ded62f4..93795b801e2 100644 --- a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts @@ -7,10 +7,13 @@ type BookshelfDocument = { type BookshelfModel = { add(data: Partial, unfilteredOptions?: unknown): Promise; - findOne(data: Record, unfilteredOptions?: unknown): Promise | null>; + edit(data: Partial, unfilteredOptions?: unknown): Promise>; + findOne(data: Record, options: {require: true}): Promise>; + findOne(data: Record, options?: {require?: false}): Promise | null>; }; type GiftRow = { + id: string; token: string; buyer_email: string; buyer_member_id: string | null; @@ -73,11 +76,47 @@ export class GiftBookshelfRepository implements GiftRepository { }); } + async update(gift: Gift): Promise { + const existing = await this.model.findOne({token: gift.token}, {require: true}); + + const id = existing.toJSON().id; + + await this.model.edit({ + 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 + }, {id}); + } + async getByToken(token: string): Promise { - const model = await this.model.findOne({ - token - }, {require: false}); + return this.toGift( + await this.model.findOne({token}, {require: false}) + ); + } + + async getByPaymentIntentId(paymentIntentId: string): Promise { + return this.toGift( + await this.model.findOne({stripe_payment_intent_id: paymentIntentId}, {require: false}) + ); + } + private toGift(model: BookshelfDocument | null): Gift | null { if (!model) { return null; } diff --git a/ghost/core/core/server/services/gifts/gift-repository.ts b/ghost/core/core/server/services/gifts/gift-repository.ts index 8ca646b2ac7..dc3d121e2a5 100644 --- a/ghost/core/core/server/services/gifts/gift-repository.ts +++ b/ghost/core/core/server/services/gifts/gift-repository.ts @@ -3,5 +3,7 @@ import type {Gift} from './gift'; export interface GiftRepository { existsByCheckoutSessionId(checkoutSessionId: string): Promise; create(gift: Gift): Promise; + update(gift: Gift): Promise; getByToken(token: string): Promise; + getByPaymentIntentId(paymentIntentId: string): Promise; } diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts index c2503413dc7..89161a53721 100644 --- a/ghost/core/core/server/services/gifts/gift-service.ts +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -167,6 +167,27 @@ export class GiftService { return true; } + async refundGift(paymentIntentId: string): Promise { + const gift = await this.giftRepository.getByPaymentIntentId(paymentIntentId); + + if (!gift) { + return false; + } + + const refunded = gift.markRefunded(); + + if (!refunded) { + return true; + } + + await this.giftRepository.update(gift); + + // TODO: if the gift was already redeemed/consumed, we should also + // downgrade the recipient member back to free. + + return true; + } + async getRedeemableGiftByToken({token, currentMember}: {token: string; currentMember?: {status: string} | null}) { if (!this.labsService.isSet('giftSubscriptions')) { throw new errors.BadRequestError({ diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts index a5cce2071e6..f5d699b3b5d 100644 --- a/ghost/core/core/server/services/gifts/gift.ts +++ b/ghost/core/core/server/services/gifts/gift.ts @@ -140,4 +140,15 @@ export class Gift { return {redeemable: true}; } + + markRefunded(): boolean { + if (this.isRefunded()) { + return false; + } + + this.status = 'refunded'; + this.refundedAt = new Date(); + + return true; + } } diff --git a/ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js new file mode 100644 index 00000000000..ca581c9c90c --- /dev/null +++ b/ghost/core/core/server/services/stripe/services/webhook/charge-refunded-event-service.js @@ -0,0 +1,59 @@ +const logging = require('@tryghost/logging'); + +/** + * Handles `charge.refunded` webhook events + * + * When a charge is refunded in Stripe, this service delegates to the + * appropriate handler based on the type of charge. + */ +module.exports = class ChargeRefundedEventService { + /** + * @param {object} deps + * @param {object} deps.giftService + */ + constructor(deps) { + this.deps = deps; + } + + /** + * Handles a `charge.refunded` event + * + * Extracts the payment intent ID from the charge and delegates to + * type-specific handlers. + * + * @param {import('stripe').Stripe.Charge} charge + */ + async handleEvent(charge) { + // payment_intent can be a string ID or an expanded PaymentIntent object + const raw = charge.payment_intent; + const paymentIntentId = typeof raw === 'string' ? raw : raw?.id; + + if (!paymentIntentId) { + logging.info('charge.refunded: no payment_intent on charge, skipping'); + + return; + } + + // One-time payments (gifts, donations) have no invoice + if (charge.invoice === null) { + await this.handleGiftRefundEvent(paymentIntentId); + } + } + + /** + * Handles a refund for a gift subscription purchase + * + * Looks up the gift by payment intent ID and marks it as refunded. + * If no gift matches, the refund is for something else and is ignored. + * + * @param {string} paymentIntentId + * @private + */ + async handleGiftRefundEvent(paymentIntentId) { + const refunded = await this.deps.giftService.refundGift(paymentIntentId); + + if (!refunded) { + logging.info(`charge.refunded: no gift found for payment_intent ${paymentIntentId}, skipping`); + } + } +}; diff --git a/ghost/core/core/server/services/stripe/stripe-service.js b/ghost/core/core/server/services/stripe/stripe-service.js index 86c04914a5e..24ef8a18cab 100644 --- a/ghost/core/core/server/services/stripe/stripe-service.js +++ b/ghost/core/core/server/services/stripe/stripe-service.js @@ -8,6 +8,7 @@ const {StripeLiveEnabledEvent, StripeLiveDisabledEvent} = require('./events'); const SubscriptionEventService = require('./services/webhook/subscription-event-service'); const InvoiceEventService = require('./services/webhook/invoice-event-service'); const CheckoutSessionEventService = require('./services/webhook/checkout-session-event-service'); +const ChargeRefundedEventService = require('./services/webhook/charge-refunded-event-service'); const memberWelcomeEmailService = require('../member-welcome-emails/service'); /** @@ -134,11 +135,18 @@ module.exports = class StripeService { } }); + const chargeRefundedEventService = new ChargeRefundedEventService({ + get giftService() { + return giftService.service; + } + }); + const webhookController = new WebhookController({ webhookManager, subscriptionEventService, invoiceEventService, - checkoutSessionEventService + checkoutSessionEventService, + chargeRefundedEventService }); this.models = models; diff --git a/ghost/core/core/server/services/stripe/webhook-controller.js b/ghost/core/core/server/services/stripe/webhook-controller.js index 73287975fc3..c4be3ead2c7 100644 --- a/ghost/core/core/server/services/stripe/webhook-controller.js +++ b/ghost/core/core/server/services/stripe/webhook-controller.js @@ -7,18 +7,21 @@ module.exports = class WebhookController { * @param {import('./services/webhook/checkout-session-event-service')} deps.checkoutSessionEventService * @param {import('./services/webhook/subscription-event-service')} deps.subscriptionEventService * @param {import('./services/webhook/invoice-event-service')} deps.invoiceEventService + * @param {import('./services/webhook/charge-refunded-event-service')} deps.chargeRefundedEventService */ constructor(deps) { this.checkoutSessionEventService = deps.checkoutSessionEventService; this.subscriptionEventService = deps.subscriptionEventService; this.invoiceEventService = deps.invoiceEventService; + this.chargeRefundedEventService = deps.chargeRefundedEventService; this.webhookManager = deps.webhookManager; this.handlers = { 'customer.subscription.deleted': this.subscriptionEvent, 'customer.subscription.updated': this.subscriptionEvent, 'customer.subscription.created': this.subscriptionEvent, 'invoice.payment_succeeded': this.invoiceEvent, - 'checkout.session.completed': this.checkoutSessionEvent + 'checkout.session.completed': this.checkoutSessionEvent, + 'charge.refunded': this.chargeRefundedEvent }; } @@ -145,4 +148,13 @@ module.exports = class WebhookController { async checkoutSessionEvent(session) { await this.checkoutSessionEventService.handleEvent(session); } + + /** + * Delegates `charge.refunded` events to the `chargeRefundedEventService` + * @param {import('stripe').Stripe.Charge} charge + * @private + */ + async chargeRefundedEvent(charge) { + await this.chargeRefundedEventService.handleEvent(charge); + } }; diff --git a/ghost/core/core/server/services/stripe/webhook-manager.js b/ghost/core/core/server/services/stripe/webhook-manager.js index b7bd01abad7..9a16d7febaf 100644 --- a/ghost/core/core/server/services/stripe/webhook-manager.js +++ b/ghost/core/core/server/services/stripe/webhook-manager.js @@ -46,7 +46,8 @@ module.exports = class WebhookManager { 'customer.subscription.deleted', 'customer.subscription.updated', 'customer.subscription.created', - 'invoice.payment_succeeded' + 'invoice.payment_succeeded', + 'charge.refunded' ]; /** diff --git a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js index 439cdeac210..7a854ee405e 100644 --- a/ghost/core/test/e2e-api/members/gift-subscriptions.test.js +++ b/ghost/core/test/e2e-api/members/gift-subscriptions.test.js @@ -179,7 +179,28 @@ describe('Gift Subscriptions', function () { assert.equal(gift.get('status'), 'purchased'); }); - it('Handles Stripe webhook idempotency', async function () { + it('Includes gift token in the purchase success URL', async function () { + const paidTier = await getPaidTier(); + + await membersAgent.post('/api/create-stripe-checkout-session/') + .body({ + type: 'gift', + tierId: paidTier.id, + cadence: 'month', + customerEmail: 'url-test-buyer@example.com', + metadata: {} + }) + .expectStatus(200); + + const checkoutSession = getLatestCheckoutSession(); + const successUrl = checkoutSession.success_url; + + assert.ok(successUrl, 'Should have a success URL'); + assert.ok(successUrl.includes('stripe=gift-purchase-success'), 'Success URL should contain stripe=gift-purchase-success'); + assert.ok(successUrl.includes(`gift_token=${checkoutSession.metadata.gift_token}`), 'Success URL should contain the gift token'); + }); + + it('Handles Stripe webhook idempotency for gift purchases', async function () { const paidTier = await getPaidTier(); await membersAgent.post('/api/create-stripe-checkout-session/') @@ -258,7 +279,7 @@ describe('Gift Subscriptions', function () { await expectGiftCheckoutError({customerEmail: 'not-an-email'}); }); - it('Includes gift token in the success URL', async function () { + it('Marks gift as refunded when Stripe charge.refunded webhook is received', async function () { const paidTier = await getPaidTier(); await membersAgent.post('/api/create-stripe-checkout-session/') @@ -266,16 +287,95 @@ describe('Gift Subscriptions', function () { type: 'gift', tierId: paidTier.id, cadence: 'month', - customerEmail: 'url-test-buyer@example.com', + customerEmail: 'refund-buyer@example.com', metadata: {} }) .expectStatus(200); const checkoutSession = getLatestCheckoutSession(); - const successUrl = checkoutSession.success_url; + const paymentIntentId = 'pi_refund_test_789'; - assert.ok(successUrl, 'Should have a success URL'); - assert.ok(successUrl.includes('stripe=gift-purchase-success'), 'Success URL should contain stripe=gift-purchase-success'); - assert.ok(successUrl.includes(`gift_token=${checkoutSession.metadata.gift_token}`), 'Success URL should contain the gift token'); + // Complete the gift purchase via webhook + await stripeMocker.sendWebhook({ + type: 'checkout.session.completed', + data: { + object: { + id: checkoutSession.id, + mode: 'payment', + amount_total: paidTier.monthly_price, + currency: paidTier.currency.toLowerCase(), + customer: checkoutSession.customer, + metadata: toWebhookMetadata(checkoutSession.metadata), + payment_intent: paymentIntentId + } + } + }); + + await DomainEvents.allSettled(); + + // Verify the gift was created + const gift = await models.Gift.findOne({ + token: checkoutSession.metadata.gift_token + }, {require: true}); + + assert.equal(gift.get('status'), 'purchased'); + + // Send charge.refunded webhook + await stripeMocker.sendWebhook({ + type: 'charge.refunded', + data: { + object: { + id: 'ch_refund_test', + payment_intent: paymentIntentId, + invoice: null + } + } + }); + + await DomainEvents.allSettled(); + + // Verify the gift is now refunded + const refundedGift = await models.Gift.findOne({ + token: checkoutSession.metadata.gift_token + }, {require: true}); + + assert.equal(refundedGift.get('status'), 'refunded'); + assert.ok(refundedGift.get('refunded_at')); + }); + + it('Ignores Stripe charge.refunded webhook for non-gift charges', async function () { + // Send a charge.refunded webhook with a payment_intent that doesn't match any gift + await stripeMocker.sendWebhook({ + type: 'charge.refunded', + data: { + object: { + id: 'ch_non_gift', + payment_intent: 'pi_non_gift_charge', + invoice: null + } + } + }); + + await DomainEvents.allSettled(); + + // No error thrown, webhook handled gracefully + }); + + it('Ignores Stripe charge.refunded webhook for subscription charges', async function () { + // Send a charge.refunded webhook with an invoice (subscription charge) + await stripeMocker.sendWebhook({ + type: 'charge.refunded', + data: { + object: { + id: 'ch_sub_refund', + payment_intent: 'pi_sub_charge', + invoice: 'in_123' + } + } + }); + + await DomainEvents.allSettled(); + + // No error thrown, webhook handled gracefully }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts index 4642dab2697..d4d46553525 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-bookshelf-repository.test.ts @@ -11,6 +11,7 @@ describe('GiftBookshelfRepository', function () { it('returns a Gift when a token matches', async function () { const GiftModel = { add: sinon.stub(), + edit: sinon.stub(), findOne: sinon.stub().resolves({ toJSON() { return { @@ -52,6 +53,7 @@ describe('GiftBookshelfRepository', function () { it('returns null when no gift matches the token', async function () { const GiftModel = { add: sinon.stub(), + edit: sinon.stub(), findOne: sinon.stub().resolves(null) }; const repository = new GiftBookshelfRepository({GiftModel}); diff --git a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts index f6fb5ec6cad..95cc087716b 100644 --- a/ghost/core/test/unit/server/services/gifts/gift-service.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -3,6 +3,7 @@ import sinon from 'sinon'; import {GiftService, type GiftPurchaseData} from '../../../../../core/server/services/gifts/gift-service'; import {Gift} from '../../../../../core/server/services/gifts/gift'; import type {GiftRepository} from '../../../../../core/server/services/gifts/gift-repository'; +import {buildGift} from './utils'; describe('GiftService', function () { let giftRepository: sinon.SinonStubbedInstance; @@ -39,8 +40,10 @@ describe('GiftService', function () { beforeEach(function () { giftRepository = { create: sinon.stub(), + update: sinon.stub(), existsByCheckoutSessionId: sinon.stub<[string], Promise>().resolves(false), - getByToken: sinon.stub<[string], Promise>().resolves(null) + getByToken: sinon.stub<[string], Promise>().resolves(null), + getByPaymentIntentId: sinon.stub<[string], Promise>().resolves(null) }; memberRepository = { get: sinon.stub().resolves({id: 'member_1', get: sinon.stub().returns(null)}) @@ -81,31 +84,6 @@ describe('GiftService', function () { }); } - function buildGift(overrides: Partial[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('recordPurchase', function () { it('creates a Gift entity and saves it', async function () { const service = createService(); @@ -487,4 +465,45 @@ describe('GiftService', function () { ); }); }); + + describe('refundGift', function () { + it('marks gift as refunded and persists it', async function () { + const gift = buildGift(); + + giftRepository.getByPaymentIntentId.resolves(gift); + + const service = createService(); + const result = await service.refundGift('pi_456'); + + assert.equal(result, true); + assert.equal(gift.status, 'refunded'); + assert.ok(gift.refundedAt); + sinon.assert.calledOnce(giftRepository.update); + sinon.assert.calledWith(giftRepository.update, gift); + }); + + it('returns false when no gift matches the payment intent', async function () { + giftRepository.getByPaymentIntentId.resolves(null); + + const service = createService(); + const result = await service.refundGift('pi_unknown'); + + assert.equal(result, false); + sinon.assert.notCalled(giftRepository.update); + }); + + it('returns true without updating when gift is already refunded', async function () { + const gift = buildGift({ + refundedAt: new Date('2026-02-01T00:00:00.000Z') + }); + + giftRepository.getByPaymentIntentId.resolves(gift); + + const service = createService(); + const result = await service.refundGift('pi_456'); + + assert.equal(result, true); + sinon.assert.notCalled(giftRepository.update); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/gifts/gift.test.ts b/ghost/core/test/unit/server/services/gifts/gift.test.ts index 8fbff87200d..2907fbe1deb 100644 --- a/ghost/core/test/unit/server/services/gifts/gift.test.ts +++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import {Gift, type GiftFromPurchaseData} from '../../../../../core/server/services/gifts/gift'; import {GIFT_EXPIRY_DAYS} from '../../../../../core/server/services/gifts/constants'; +import {buildGift} from './utils'; describe('Gift', function () { const purchaseData: GiftFromPurchaseData = { @@ -69,31 +70,6 @@ describe('Gift', function () { }); describe('redeemability', function () { - function buildGift(overrides: Partial[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 - }); - } - it('is redeemable when it has not been redeemed, consumed, expired, or refunded', function () { const gift = buildGift(); @@ -132,4 +108,33 @@ describe('Gift', function () { assert.deepEqual(gift.checkRedeemable(), {redeemable: false, reason: 'refunded'}); }); }); + + describe('markRefunded', function () { + it('sets status to refunded and refundedAt to now and returns true', function () { + const gift = buildGift(); + const before = new Date(); + + const result = gift.markRefunded(); + + const after = new Date(); + + assert.equal(result, true); + assert.equal(gift.status, 'refunded'); + assert.ok(gift.refundedAt); + assert.ok(gift.refundedAt >= before); + assert.ok(gift.refundedAt <= after); + }); + + it('returns false without changing state if already refunded', function () { + const originalRefundedAt = new Date('2026-02-01T00:00:00.000Z'); + const gift = buildGift({ + refundedAt: originalRefundedAt + }); + + const result = gift.markRefunded(); + + assert.equal(result, false); + assert.equal(gift.refundedAt, originalRefundedAt); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/gifts/utils.ts b/ghost/core/test/unit/server/services/gifts/utils.ts new file mode 100644 index 00000000000..de8e333ffba --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/utils.ts @@ -0,0 +1,26 @@ +import {Gift} from '../../../../../core/server/services/gifts/gift'; + +export function buildGift(overrides: Partial[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 + }); +} diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js new file mode 100644 index 00000000000..f7b2406b351 --- /dev/null +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/charge-refunded-event-service.test.js @@ -0,0 +1,66 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); + +const ChargeRefundedEventService = require('../../../../../../../core/server/services/stripe/services/webhook/charge-refunded-event-service'); + +describe('ChargeRefundedEventService', function () { + let giftService; + + beforeEach(function () { + giftService = { + refundGift: sinon.stub() + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('calls giftService.refundGift with the payment_intent from the charge', async function () { + giftService.refundGift.resolves(true); + + const service = new ChargeRefundedEventService({giftService}); + await service.handleEvent({payment_intent: 'pi_123', invoice: null}); + + sinon.assert.calledOnce(giftService.refundGift); + sinon.assert.calledWith(giftService.refundGift, 'pi_123'); + }); + + it('does not throw when no gift matches the payment intent', async function () { + giftService.refundGift.resolves(false); + + const service = new ChargeRefundedEventService({giftService}); + + await assert.doesNotReject( + () => service.handleEvent({payment_intent: 'pi_unknown', invoice: null}) + ); + + sinon.assert.calledOnce(giftService.refundGift); + }); + + it('extracts id from an expanded payment_intent object', async function () { + giftService.refundGift.resolves(true); + + const service = new ChargeRefundedEventService({giftService}); + await service.handleEvent({payment_intent: {id: 'pi_expanded'}, invoice: null}); + + sinon.assert.calledOnce(giftService.refundGift); + sinon.assert.calledWith(giftService.refundGift, 'pi_expanded'); + }); + + it('skips processing when the charge has no payment_intent', async function () { + const service = new ChargeRefundedEventService({giftService}); + + await service.handleEvent({payment_intent: null, invoice: null}); + + sinon.assert.notCalled(giftService.refundGift); + }); + + it('skips processing when the charge has an invoice (subscription refund)', async function () { + const service = new ChargeRefundedEventService({giftService}); + + await service.handleEvent({payment_intent: 'pi_123', invoice: 'in_123'}); + + sinon.assert.notCalled(giftService.refundGift); + }); +}); diff --git a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js index 4745c6c05c9..0aa63147ebe 100644 --- a/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js +++ b/ghost/core/test/unit/server/services/stripe/webhook-controller.test.js @@ -12,6 +12,7 @@ describe('WebhookController', function () { subscriptionEventService: {handleSubscriptionEvent: sinon.stub()}, invoiceEventService: {handleInvoiceEvent: sinon.stub()}, checkoutSessionEventService: {handleEvent: sinon.stub(), handleDonationEvent: sinon.stub()}, + chargeRefundedEventService: {handleEvent: sinon.stub()}, webhookManager: {parseWebhook: sinon.stub()} }; @@ -185,6 +186,24 @@ describe('WebhookController', function () { sinon.assert.called(res.end); }); + it('should handle charge.refunded event', async function () { + const event = { + type: 'charge.refunded', + data: { + object: {payment_intent: 'pi_123'} + } + }; + + deps.webhookManager.parseWebhook.returns(event); + + await controller.handle(req, res); + + sinon.assert.calledOnce(deps.chargeRefundedEventService.handleEvent); + sinon.assert.calledWith(deps.chargeRefundedEventService.handleEvent, {payment_intent: 'pi_123'}); + sinon.assert.calledWith(res.writeHead, 200); + sinon.assert.called(res.end); + }); + it('should not handle unknown event type', async function () { const event = { type: 'invalid.event',