diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index 05f610cdfe9..8ce26707e7e 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -334,6 +334,7 @@ async function initServices() { const slackNotifications = require('./server/services/slack-notifications'); const mediaInliner = require('./server/services/media-inliner'); const donationService = require('./server/services/donations'); + const giftService = require('./server/services/gifts'); const recommendationsService = require('./server/services/recommendations'); const emailAddressService = require('./server/services/email-address'); const statsService = require('./server/services/stats'); @@ -374,6 +375,7 @@ async function initServices() { slackNotifications.init(), mediaInliner.init(), donationService.init(), + giftService.init(), recommendationsService.init(), statsService.init(), explorePingService.init() diff --git a/ghost/core/core/server/models/gift.js b/ghost/core/core/server/models/gift.js new file mode 100644 index 00000000000..bdf8cf70d60 --- /dev/null +++ b/ghost/core/core/server/models/gift.js @@ -0,0 +1,26 @@ +const errors = require('@tryghost/errors'); +const ghostBookshelf = require('./base'); + +const Gift = ghostBookshelf.Model.extend({ + tableName: 'gifts', + + buyer() { + return this.belongsTo('Member', 'buyer_member_id', 'id'); + }, + + redeemer() { + return this.belongsTo('Member', 'redeemer_member_id', 'id'); + }, + + tier() { + return this.belongsTo('Product', 'tier_id', 'id'); + } +}, { + async destroy() { + throw new errors.IncorrectUsageError({message: 'Cannot destroy Gift'}); + } +}); + +module.exports = { + Gift: ghostBookshelf.model('Gift', Gift) +}; diff --git a/ghost/core/core/server/services/gifts/constants.ts b/ghost/core/core/server/services/gifts/constants.ts new file mode 100644 index 00000000000..8738d2a97ff --- /dev/null +++ b/ghost/core/core/server/services/gifts/constants.ts @@ -0,0 +1 @@ +export const GIFT_EXPIRY_DAYS = 365; diff --git a/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts new file mode 100644 index 00000000000..eac37ce8212 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts @@ -0,0 +1,48 @@ +import {Gift} from './gift'; + +type BookshelfModel = { + add(data: Partial, unfilteredOptions?: unknown): Promise; + findOne(data: Record, unfilteredOptions?: unknown): Promise; +}; + +type GiftBookshelfModel = BookshelfModel>; + +export class GiftBookshelfRepository { + readonly #Model: GiftBookshelfModel; + + constructor({GiftModel}: {GiftModel: GiftBookshelfModel}) { + this.#Model = GiftModel; + } + + async existsByCheckoutSessionId(checkoutSessionId: string): Promise { + const existing = await this.#Model.findOne({ + stripe_checkout_session_id: checkoutSessionId + }, {require: false}); + + return !!existing; + } + + async create(gift: Gift) { + await this.#Model.add({ + 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 + }); + } +} diff --git a/ghost/core/core/server/services/gifts/gift-service-wrapper.js b/ghost/core/core/server/services/gifts/gift-service-wrapper.js new file mode 100644 index 00000000000..ef79022374e --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-service-wrapper.js @@ -0,0 +1,27 @@ +class GiftServiceWrapper { + service; + + async init() { + if (this.service) { + return; + } + + const {Gift: GiftModel} = require('../../models'); + const {GiftBookshelfRepository} = require('./gift-bookshelf-repository'); + const {GiftService} = require('./gift-service'); + const membersService = require('../members'); + + const repository = new GiftBookshelfRepository({ + GiftModel + }); + + this.service = new GiftService({ + giftRepository: repository, + get memberRepository() { + return membersService.api.members; + } + }); + } +} + +module.exports = GiftServiceWrapper; diff --git a/ghost/core/core/server/services/gifts/gift-service.ts b/ghost/core/core/server/services/gifts/gift-service.ts new file mode 100644 index 00000000000..25b387f5a44 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift-service.ts @@ -0,0 +1,63 @@ +import errors from '@tryghost/errors'; +import {Gift} from './gift'; +import type {GiftBookshelfRepository} from './gift-bookshelf-repository'; + +interface MemberRepository { + get(filter: Record): Promise<{id: string} | null>; +} + +interface GiftPurchaseData { + token: string; + buyerEmail: string; + stripeCustomerId: string | null; + tierId: string; + cadence: 'month' | 'year'; + duration: string; + currency: string; + amount: number; + stripeCheckoutSessionId: string; + stripePaymentIntentId: string; +} + +export class GiftService { + readonly #giftRepository: GiftBookshelfRepository; + readonly #memberRepository: MemberRepository; + + constructor({giftRepository, memberRepository}: {giftRepository: GiftBookshelfRepository; memberRepository: MemberRepository}) { + this.#giftRepository = giftRepository; + this.#memberRepository = memberRepository; + } + + async recordPurchase(data: GiftPurchaseData): Promise { + const duration = Number.parseInt(data.duration); + + if (Number.isNaN(duration)) { + throw new errors.ValidationError({message: `Invalid gift duration: ${data.duration}`}); + } + + if (await this.#giftRepository.existsByCheckoutSessionId(data.stripeCheckoutSessionId)) { + return false; + } + + const member = data.stripeCustomerId + ? await this.#memberRepository.get({customer_id: data.stripeCustomerId}) + : null; + + const gift = Gift.fromPurchase({ + token: data.token, + buyerEmail: data.buyerEmail, + buyerMemberId: member?.id ?? null, + tierId: data.tierId, + cadence: data.cadence, + duration, + currency: data.currency, + amount: data.amount, + stripeCheckoutSessionId: data.stripeCheckoutSessionId, + stripePaymentIntentId: data.stripePaymentIntentId + }); + + await this.#giftRepository.create(gift); + + return true; + } +} diff --git a/ghost/core/core/server/services/gifts/gift.ts b/ghost/core/core/server/services/gifts/gift.ts new file mode 100644 index 00000000000..e328c21ac60 --- /dev/null +++ b/ghost/core/core/server/services/gifts/gift.ts @@ -0,0 +1,103 @@ +import {GIFT_EXPIRY_DAYS} from './constants'; + +type GiftStatus = 'purchased' | 'redeemed' | 'consumed' | 'expired' | 'refunded'; +type GiftCadence = 'month' | 'year'; + +interface GiftData { + token: string; + buyerEmail: string; + buyerMemberId: string | null; + redeemerMemberId: string | null; + tierId: string; + cadence: GiftCadence; + duration: number; + currency: string; + amount: number; + stripeCheckoutSessionId: string; + stripePaymentIntentId: string; + consumesAt: Date | null; + expiresAt: Date | null; + status: GiftStatus; + purchasedAt: Date; + redeemedAt: Date | null; + consumedAt: Date | null; + expiredAt: Date | null; + refundedAt: Date | null; +} + +interface GiftPurchaseData { + token: string; + buyerEmail: string; + buyerMemberId: string | null; + tierId: string; + cadence: GiftCadence; + duration: number; + currency: string; + amount: number; + stripeCheckoutSessionId: string; + stripePaymentIntentId: string; +} + +export class Gift { + token: string; + buyerEmail: string; + buyerMemberId: string | null; + redeemerMemberId: string | null; + tierId: string; + cadence: GiftCadence; + duration: number; + currency: string; + amount: number; + stripeCheckoutSessionId: string; + stripePaymentIntentId: string; + consumesAt: Date | null; + expiresAt: Date | null; + status: GiftStatus; + purchasedAt: Date; + redeemedAt: Date | null; + consumedAt: Date | null; + expiredAt: Date | null; + refundedAt: Date | null; + + constructor(data: GiftData) { + this.token = data.token; + this.buyerEmail = data.buyerEmail; + this.buyerMemberId = data.buyerMemberId; + this.redeemerMemberId = data.redeemerMemberId; + this.tierId = data.tierId; + this.cadence = data.cadence; + this.duration = data.duration; + this.currency = data.currency; + this.amount = data.amount; + this.stripeCheckoutSessionId = data.stripeCheckoutSessionId; + this.stripePaymentIntentId = data.stripePaymentIntentId; + this.consumesAt = data.consumesAt; + this.expiresAt = data.expiresAt; + this.status = data.status; + this.purchasedAt = data.purchasedAt; + this.redeemedAt = data.redeemedAt; + this.consumedAt = data.consumedAt; + this.expiredAt = data.expiredAt; + this.refundedAt = data.refundedAt; + } + + static fromPurchase(data: GiftPurchaseData) { + const now = new Date(); + const expiresAt = new Date(now); + + expiresAt.setDate(expiresAt.getDate() + GIFT_EXPIRY_DAYS); + + return new Gift({ + ...data, + redeemerMemberId: null, + consumesAt: null, + expiresAt, + status: 'purchased', + purchasedAt: now, + redeemedAt: null, + consumedAt: null, + expiredAt: null, + refundedAt: null + }); + } +} diff --git a/ghost/core/core/server/services/gifts/index.js b/ghost/core/core/server/services/gifts/index.js new file mode 100644 index 00000000000..a2cb03879bd --- /dev/null +++ b/ghost/core/core/server/services/gifts/index.js @@ -0,0 +1,3 @@ +const GiftServiceWrapper = require('./gift-service-wrapper'); + +module.exports = new GiftServiceWrapper(); diff --git a/ghost/core/core/server/services/stripe/service.js b/ghost/core/core/server/services/stripe/service.js index fa4dfc54f9c..82d39c9563a 100644 --- a/ghost/core/core/server/services/stripe/service.js +++ b/ghost/core/core/server/services/stripe/service.js @@ -10,6 +10,7 @@ const models = require('../../models'); const {getConfig} = require('./config'); const settingsHelpers = require('../settings-helpers'); const donationService = require('../donations'); +const giftService = require('../gifts'); const staffService = require('../staff'); const labs = require('../../../shared/labs'); const settingsCache = require('../../../shared/settings-cache'); @@ -60,6 +61,7 @@ module.exports = new StripeService({ } }, donationService, + giftService, staffService, settingsCache }); diff --git a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js index 30336cd3f12..13b6904ad7e 100644 --- a/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js +++ b/ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js @@ -14,6 +14,7 @@ const { * It is triggered for the following scenarios: * - Subscription * - Donation + * - Gift purchase * - Setup intent * * This service delegates the event to the appropriate handler based on the session mode and metadata. @@ -26,6 +27,7 @@ module.exports = class CheckoutSessionEventService { * @param {import('../../stripe-api')} deps.api * @param {object} deps.memberRepository * @param {object} deps.donationRepository + * @param {object} deps.giftService * @param {object} deps.staffServiceEmails * @param {function} deps.sendSignupEmail * @param {function} deps.isPaidWelcomeEmailActive @@ -51,9 +53,31 @@ module.exports = class CheckoutSessionEventService { if (session.mode === 'payment' && session.metadata?.ghost_donation) { await this.handleDonationEvent(session); + } else if (session.mode === 'payment' && session.metadata?.ghost_gift) { + await this.handleGiftEvent(session); } } + /** + * Handles a `checkout.session.completed` event for a gift subscription purchase + * + * @param {import('stripe').Stripe.Checkout.Session} session + */ + async handleGiftEvent(session) { + await this.deps.giftService.recordPurchase({ + token: session.metadata?.gift_token, + buyerEmail: session.metadata?.purchaser_email, + stripeCustomerId: session.customer ?? null, + tierId: session.metadata?.tier_id, + cadence: session.metadata?.cadence, + duration: session.metadata?.duration, + currency: session.currency, + amount: session.amount_total, + stripeCheckoutSessionId: session.id, + stripePaymentIntentId: session.payment_intent + }); + } + /** * Handles a `checkout.session.completed` event for a donation * @param {import('stripe').Stripe.Checkout.Session} session diff --git a/ghost/core/core/server/services/stripe/stripe-service.js b/ghost/core/core/server/services/stripe/stripe-service.js index b5ce24d5629..86c04914a5e 100644 --- a/ghost/core/core/server/services/stripe/stripe-service.js +++ b/ghost/core/core/server/services/stripe/stripe-service.js @@ -37,6 +37,7 @@ module.exports = class StripeService { * @param {*} deps.labs * @param {*} deps.membersService * @param {*} deps.donationService + * @param {*} deps.giftService * @param {*} deps.staffService * @param {import('./webhook-manager').StripeWebhook} deps.StripeWebhook * @param {object} deps.settingsCache @@ -53,6 +54,7 @@ module.exports = class StripeService { labs, membersService, donationService, + giftService, staffService, StripeWebhook, settingsCache, @@ -110,6 +112,9 @@ module.exports = class StripeService { get donationRepository(){ return donationService.repository; }, + get giftService(){ + return giftService.service; + }, get staffServiceEmails(){ return staffService.api.emails; }, 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 new file mode 100644 index 00000000000..eab9d3cb4ac --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift-service.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import {GiftService} from '../../../../../core/server/services/gifts/gift-service'; +import {Gift} from '../../../../../core/server/services/gifts/gift'; + +describe('GiftService', function () { + let giftRepository: { + create: sinon.SinonStub; + existsByCheckoutSessionId: sinon.SinonStub; + }; + let memberRepository: { + get: sinon.SinonStub; + }; + const purchaseData = { + token: 'abc-123', + buyerEmail: 'buyer@example.com', + stripeCustomerId: 'cust_123', + tierId: 'tier_1', + cadence: 'year' as const, + duration: '1', + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_456' + }; + + beforeEach(function () { + giftRepository = { + create: sinon.stub(), + existsByCheckoutSessionId: sinon.stub().resolves(false) + }; + memberRepository = { + get: sinon.stub().resolves({id: 'member_1'}) + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + function createService() { + return new GiftService({giftRepository: giftRepository as any, memberRepository}); + } + + describe('recordPurchase', function () { + it('creates a Gift entity and saves it', async function () { + const service = createService(); + + const result = await service.recordPurchase(purchaseData); + + assert.equal(result, true); + sinon.assert.calledOnce(giftRepository.create); + + const gift = giftRepository.create.getCall(0).args[0]; + + assert.ok(gift instanceof Gift); + assert.equal(gift.token, 'abc-123'); + assert.equal(gift.status, 'purchased'); + }); + + it('returns false and skips create for duplicate checkout session', async function () { + giftRepository.existsByCheckoutSessionId.resolves(true); + + const service = createService(); + const result = await service.recordPurchase(purchaseData); + + assert.equal(result, false); + + sinon.assert.notCalled(giftRepository.create); + }); + + it('resolves member by stripeCustomerId', async function () { + const service = createService(); + + await service.recordPurchase(purchaseData); + + sinon.assert.calledWith(memberRepository.get, {customer_id: 'cust_123'}); + + const gift = giftRepository.create.getCall(0).args[0]; + + assert.equal(gift.buyerMemberId, 'member_1'); + }); + + it('sets buyerMemberId to null when stripeCustomerId is null', async function () { + const service = createService(); + + await service.recordPurchase({...purchaseData, stripeCustomerId: null}); + + sinon.assert.notCalled(memberRepository.get); + + const gift = giftRepository.create.getCall(0).args[0]; + + assert.equal(gift.buyerMemberId, null); + }); + + it('sets buyerMemberId to null when member not found', async function () { + memberRepository.get.resolves(null); + + const service = createService(); + + await service.recordPurchase(purchaseData); + + const gift = giftRepository.create.getCall(0).args[0]; + + assert.equal(gift.buyerMemberId, null); + }); + + it('parses duration from string to number', async function () { + const service = createService(); + + await service.recordPurchase({...purchaseData, duration: '3'}); + + const gift = giftRepository.create.getCall(0).args[0]; + + assert.equal(gift.duration, 3); + }); + + it('throws ValidationError for invalid duration', async function () { + const service = createService(); + + await assert.rejects( + () => service.recordPurchase({...purchaseData, duration: 'invalid'}), + (err: any) => { + assert.equal(err.errorType, 'ValidationError'); + + assert.ok(err.message.includes('invalid')); + + return true; + } + ); + + sinon.assert.notCalled(giftRepository.create); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/gifts/gift.test.ts b/ghost/core/test/unit/server/services/gifts/gift.test.ts new file mode 100644 index 00000000000..e62bda26395 --- /dev/null +++ b/ghost/core/test/unit/server/services/gifts/gift.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import {Gift} from '../../../../../core/server/services/gifts/gift'; +import {GIFT_EXPIRY_DAYS} from '../../../../../core/server/services/gifts/constants'; + +describe('Gift', function () { + const purchaseData = { + token: 'abc-123', + buyerEmail: 'buyer@example.com', + buyerMemberId: 'member_1', + tierId: 'tier_1', + cadence: 'year' as const, + duration: 1, + currency: 'usd', + amount: 5000, + stripeCheckoutSessionId: 'cs_123', + stripePaymentIntentId: 'pi_456' + }; + + describe('fromPurchase', function () { + it('sets status to purchased', function () { + const gift = Gift.fromPurchase(purchaseData); + + assert.equal(gift.status, 'purchased'); + }); + + it('sets purchasedAt to now', function () { + const before = new Date(); + const gift = Gift.fromPurchase(purchaseData); + const after = new Date(); + + assert.ok(gift.purchasedAt >= before); + assert.ok(gift.purchasedAt <= after); + }); + + it('sets expiresAt to GIFT_EXPIRY_DAYS after purchasedAt', function () { + const gift = Gift.fromPurchase(purchaseData); + const daysDiff = Math.round( + (gift.expiresAt!.getTime() - gift.purchasedAt.getTime()) / (1000 * 60 * 60 * 24) + ); + + assert.equal(daysDiff, GIFT_EXPIRY_DAYS); + }); + + it('sets null defaults for redemption fields', function () { + const gift = Gift.fromPurchase(purchaseData); + + assert.equal(gift.redeemerMemberId, null); + assert.equal(gift.consumesAt, null); + assert.equal(gift.redeemedAt, null); + assert.equal(gift.consumedAt, null); + assert.equal(gift.expiredAt, null); + assert.equal(gift.refundedAt, null); + }); + + it('passes through purchase data', function () { + const gift = Gift.fromPurchase(purchaseData); + + assert.equal(gift.token, 'abc-123'); + assert.equal(gift.buyerEmail, 'buyer@example.com'); + assert.equal(gift.buyerMemberId, 'member_1'); + assert.equal(gift.tierId, 'tier_1'); + assert.equal(gift.cadence, 'year'); + assert.equal(gift.duration, 1); + assert.equal(gift.currency, 'usd'); + assert.equal(gift.amount, 5000); + assert.equal(gift.stripeCheckoutSessionId, 'cs_123'); + assert.equal(gift.stripePaymentIntentId, 'pi_456'); + }); + }); +}); diff --git a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js index 9899b80c196..6ca01d0eb91 100644 --- a/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js +++ b/ghost/core/test/unit/server/services/stripe/services/webhooks/checkout-session-event-service.test.js @@ -5,7 +5,7 @@ const sinon = require('sinon'); const CheckoutSessionEventService = require('../../../../../../../core/server/services/stripe/services/webhook/checkout-session-event-service'); describe('CheckoutSessionEventService', function () { - let api, memberRepository, donationRepository, staffServiceEmails, sendSignupEmail, isPaidWelcomeEmailActive; + let api, memberRepository, donationRepository, giftService, staffServiceEmails, sendSignupEmail, isPaidWelcomeEmailActive; beforeEach(function () { api = { @@ -31,6 +31,10 @@ describe('CheckoutSessionEventService', function () { save: sinon.stub() }; + giftService = { + recordPurchase: sinon.stub().resolves(true) + }; + staffServiceEmails = { notifyDonationReceived: sinon.stub() }; @@ -44,6 +48,7 @@ describe('CheckoutSessionEventService', function () { api, memberRepository, donationRepository, + giftService, staffServiceEmails, sendSignupEmail, isPaidWelcomeEmailActive, @@ -82,18 +87,30 @@ describe('CheckoutSessionEventService', function () { sinon.assert.calledWith(handleDonationEventStub, session); }); - it('should do nothing if session mode is not setup, subscription, or payment', async function () { + it('should call handleGiftEvent if session mode is payment and session metadata ghost_gift is present', async function () { + const service = createService(); + const session = {mode: 'payment', metadata: {ghost_gift: 'true'}}; + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); + + await service.handleEvent(session); + + sinon.assert.calledWith(handleGiftEventStub, session); + }); + + it('should do nothing if session mode is unsupported', async function () { const service = createService(); const session = {mode: 'unsupported_mode'}; const handleSetupEventStub = sinon.stub(service, 'handleSetupEvent'); const handleSubscriptionEventStub = sinon.stub(service, 'handleSubscriptionEvent'); const handleDonationEventStub = sinon.stub(service, 'handleDonationEvent'); + const handleGiftEventStub = sinon.stub(service, 'handleGiftEvent'); await service.handleEvent(session); sinon.assert.notCalled(handleSetupEventStub); sinon.assert.notCalled(handleSubscriptionEventStub); sinon.assert.notCalled(handleDonationEventStub); + sinon.assert.notCalled(handleGiftEventStub); }); }); @@ -547,6 +564,71 @@ describe('CheckoutSessionEventService', function () { }); }); + describe('handleGiftEvent', function () { + it('calls giftService.recordPurchase with session data', async function () { + const service = createService(); + const session = { + id: 'cs_test_123', + mode: 'payment', + amount_total: 5000, + currency: 'usd', + customer: 'cust_123', + payment_intent: 'pi_test_456', + metadata: { + ghost_gift: 'true', + gift_token: 'abc-123-token', + tier_id: 'tier_456', + cadence: 'year', + duration: '1', + purchaser_email: 'buyer@example.com' + } + }; + + await service.handleGiftEvent(session); + + sinon.assert.calledOnce(giftService.recordPurchase); + + const purchaseData = giftService.recordPurchase.getCall(0).args[0]; + + assert.equal(purchaseData.token, 'abc-123-token'); + assert.equal(purchaseData.buyerEmail, 'buyer@example.com'); + assert.equal(purchaseData.stripeCustomerId, 'cust_123'); + assert.equal(purchaseData.tierId, 'tier_456'); + assert.equal(purchaseData.cadence, 'year'); + assert.equal(purchaseData.duration, '1'); + assert.equal(purchaseData.currency, 'usd'); + assert.equal(purchaseData.amount, 5000); + assert.equal(purchaseData.stripeCheckoutSessionId, 'cs_test_123'); + assert.equal(purchaseData.stripePaymentIntentId, 'pi_test_456'); + }); + + it('passes null stripeCustomerId for unauthenticated purchasers', async function () { + const service = createService(); + const session = { + id: 'cs_test_123', + mode: 'payment', + amount_total: 3000, + currency: 'gbp', + customer: null, + payment_intent: 'pi_test_789', + metadata: { + ghost_gift: 'true', + gift_token: 'def-456-token', + tier_id: 'tier_111', + cadence: 'month', + duration: '1', + purchaser_email: 'guest@example.com' + } + }; + + await service.handleGiftEvent(session); + + const purchaseData = giftService.recordPurchase.getCall(0).args[0]; + + assert.equal(purchaseData.stripeCustomerId, null); + }); + }); + describe('handleSubscriptionEvent', function () { let service; let session;