import crypto from 'node:crypto'; import errors from '@tryghost/errors'; import logging from '@tryghost/logging'; import type {Knex} from 'knex'; import {z} from 'zod'; import {Gift} from './gift'; import type {GiftEventBrowseOptions, GiftEventPage, GiftRepository} from './gift-bookshelf-repository'; import type {GiftReminderScheduler} from './gift-reminder-scheduler'; import {GiftCadenceSchema, type GiftCadence} from './gift-schema'; import tpl from '@tryghost/tpl'; import {GIFT_REMINDER_FLOOR_DAYS, GIFT_REMINDER_LEAD_DAYS} from './constants'; import { resolveGiftDuration, validateGiftCheckoutOffer, type GiftCheckoutTier, type ResolvedGiftDuration } from './gift-checkout-offer'; const MS_PER_DAY = 24 * 60 * 60 * 1000; const GIFT_REMINDER_LEAD_MS = GIFT_REMINDER_LEAD_DAYS * MS_PER_DAY; const GIFT_REMINDER_FLOOR_MS = GIFT_REMINDER_FLOOR_DAYS * MS_PER_DAY; const errorMessages = { giftNotFound: 'This gift does not exist.', giftAlreadyRedeemed: 'This gift has already been redeemed.', giftConsumed: 'This gift has already been consumed.', giftExpired: 'This gift has expired.', giftRefunded: 'This gift has been refunded.', paidMember: 'You already have an active subscription.', giftInvalidReassignStatus: 'This gift does not have a reassignable status.', giftInvalidReassignMember: 'Member already has an active subscription.', giftAlreadyAssigned: 'This gift is already assigned to another member.', giftMissingConsumesAt: 'This gift is missing a "consumes at" date.', giftMemberAlreadyHasGift: 'Member already has a different active gift attached.' }; interface MemberModel { id: string; get(key: 'email'): string; get(key: 'status'): string; get(key: 'name'): string | null; get(key: 'email_disabled'): boolean; get(key: string): unknown; } interface MemberRepository { get(filter: Record, options?: Record): Promise; update(data: Record, options?: Record): Promise; triggerMemberSignupAutomation( memberId: string, memberEmail: string, memberStatus: 'free' | 'paid', options?: Record ): Promise; } type Tier = Omit & { id: string | { toHexString(): string; }; name: string; currency: string; getPrice(cadence: GiftCadence): number; toJSON(): { id: string; name: string; description: string | null; benefits: string[]; }; }; interface TiersService { api: { read(idString: string): Promise; }; } interface GiftEmailService { sendPurchaseConfirmation(data: { buyerEmail: string; token: string; tierName: string; cadence: GiftCadence; duration: number; expiresAt: Date; }): Promise; sendReminder(data: { memberEmail: string; memberName: string | null; tierName: string; consumesAt: Date; }): Promise; } interface StaffServiceEmails { notifyGiftPurchased(data: { name: string | null; email: string; memberId: string | null; amount: number; currency: string; tierName: string; cadence: GiftCadence; duration: number; }): Promise; notifyGiftSubscriptionStarted(data: { memberId: string; memberEmail: string; memberName: string | null; tierName: string; cadence: GiftCadence; duration: number; buyerEmail: string; }): Promise; } const GiftPurchaseDataSchema = z.object({ token: z.string().min(1), buyerEmail: z.string().min(1), stripeCustomerId: z.string().min(1).nullable(), tierId: z.string().min(1), cadence: GiftCadenceSchema, duration: z.number().int().positive(), currency: z.string().min(1), amount: z.number().int().nonnegative(), stripeCheckoutSessionId: z.string().min(1), stripePaymentIntentId: z.string().min(1) }); export type GiftPurchaseData = z.infer; interface GiftServiceDeps { giftRepository: GiftRepository; memberRepository: MemberRepository; tiersService: TiersService; giftEmailService: GiftEmailService; staffServiceEmails: StaffServiceEmails; giftReminderScheduler: Pick; checkoutAdapter: { getCustomerId(buyer: GiftCheckoutBuyer): Promise; createSession(data: GiftCheckoutSession): Promise; }; labsService: { isSet(flag: string): boolean; }; settingsCache: { get(key: string): unknown; }; } interface ReminderSend { memberEmail: string; memberName: string | null; consumesAt: Date; } export interface GiftCheckoutBuyer { memberId: string | null; email: string | null; name: string | null; isAuthenticated: boolean; } export interface StartGiftCheckoutInput { tierId?: string; offerId?: string; cadence?: string; duration?: number; metadata: Record; successUrl: string; cancelUrl?: string; buyer: GiftCheckoutBuyer; } interface GiftCheckoutSession { amount: number; currency: string; tierName: string; cadence: GiftCadence; duration: number; metadata: Record; successUrl: string; cancelUrl?: string; customerId: string | null; customerEmail: string | null; } export interface GiftRedemption { token: string; cadence: GiftCadence; duration: number; currency: string; amount: number; expires_at: Date; consumes_at: Date | null; tier: { id: string; name: string; description: string | null; benefits: string[]; }; } export interface GiftContinuation { tierId: string; cadence: GiftCadence; trialDays: number | null; } export interface GiftMemberPresentation { cadence: GiftCadence; currency: string; amount: number; } export interface GiftPreview { cadence: GiftCadence; duration: number; tier: { id: string; name: string; }; } export class GiftService { private readonly deps: GiftServiceDeps; constructor(deps: GiftServiceDeps) { this.deps = deps; } async startCheckout(input: StartGiftCheckoutInput): Promise<{url: string}> { if (input.offerId) { throw new errors.BadRequestError({ message: 'Bad Request.', context: 'Offers cannot be applied to gift subscriptions' }); } if (!input.tierId) { throw new errors.BadRequestError({ message: 'Bad Request.', context: 'Expected offerId or tierId, received none' }); } let resolvedDuration: ResolvedGiftDuration | null = null; let cadence: GiftCadence; if (this.deps.labsService.isSet('giftSubCustomization')) { resolvedDuration = resolveGiftDuration(input); cadence = resolvedDuration.cadence; } else { if (input.cadence !== 'month' && input.cadence !== 'year') { const receivedCadence = input.cadence ? `"${input.cadence}"` : input.cadence; throw new errors.BadRequestError({ message: 'Bad Request.', context: `Expected cadence to be "month" or "year", received ${receivedCadence}` }); } cadence = input.cadence; } let tier: Tier | null; try { tier = await this.deps.tiersService.api.read(input.tierId); } catch (err) { logging.error(err); tier = null; } if (!tier) { throw new errors.BadRequestError({ message: 'This tier does not exist.', context: `Tier with id "${input.tierId}" not found` }); } if (tier.status === 'archived') { throw new errors.NoPermissionError({ message: 'This tier is archived.' }); } let duration = 1; let totalMonths: number | undefined; let amount = tier.getPrice(cadence); if (resolvedDuration) { const plan = validateGiftCheckoutOffer({ tier, portalPlans: this.deps.settingsCache.get('portal_plans'), offer: resolvedDuration }); cadence = plan.cadence; duration = plan.duration; totalMonths = plan.totalMonths; amount = plan.amount; } const tierId = typeof tier.id === 'string' ? tier.id : tier.id.toHexString(); const token = this.generateToken(); const successUrl = new URL(input.successUrl); successUrl.searchParams.set('stripe', 'gift-purchase-success'); successUrl.searchParams.set('gift_token', token); successUrl.searchParams.set('gift_tier', tierId); successUrl.searchParams.set('gift_cadence', cadence); if (totalMonths !== undefined) { successUrl.searchParams.set('gift_duration', String(totalMonths)); } const customerId = input.buyer.isAuthenticated ? await this.deps.checkoutAdapter.getCustomerId(input.buyer) : null; const url = await this.deps.checkoutAdapter.createSession({ amount, currency: tier.currency.toLowerCase(), tierName: tier.name, cadence, duration, metadata: { ...input.metadata, ghost_gift: 'true', gift_token: token, tier_id: tierId, cadence, duration: String(duration) }, successUrl: successUrl.toString(), cancelUrl: input.cancelUrl, customerId, customerEmail: customerId ? null : input.buyer.email }); return {url}; } private generateToken(): string { /** * Combinations: 62^12 ≈ 3.23 × 10^21 (~3.23 sextillion) * Entropy: 12 × log2(62) ≈ 71.45 bits */ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let token = ''; for (let i = 0; i < 12; i++) { token += alphabet[crypto.randomInt(alphabet.length)]; } return token; } async completePurchase(input: GiftPurchaseData): Promise { const parsed = GiftPurchaseDataSchema.safeParse(input); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new errors.ValidationError({ message: 'Invalid gift purchase data.', property: issue.path.join('.'), context: issue.message }); } const data = parsed.data; if (await this.deps.giftRepository.existsByCheckoutSessionId(data.stripeCheckoutSessionId)) { return false; } const member = data.stripeCustomerId ? await this.deps.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: data.duration, currency: data.currency, amount: data.amount, stripeCheckoutSessionId: data.stripeCheckoutSessionId, stripePaymentIntentId: data.stripePaymentIntentId }); await this.deps.giftRepository.create(gift); const tier = await this.deps.tiersService.api.read(data.tierId); if (!tier) { throw new errors.NotFoundError({message: `Tier not found: ${data.tierId}`}); } try { await this.deps.staffServiceEmails.notifyGiftPurchased({ name: member?.get('name') ?? null, email: member?.get('email') ?? data.buyerEmail, memberId: member?.id ?? null, amount: data.amount, currency: data.currency, tierName: tier.name, cadence: data.cadence, duration: data.duration }); } catch (err) { logging.error('Failed to notify staff of gift purchase', err); } try { await this.deps.giftEmailService.sendPurchaseConfirmation({ buyerEmail: data.buyerEmail, token: data.token, tierName: tier.name, cadence: data.cadence, duration: data.duration, expiresAt: gift.expiresAt }); } catch (err) { logging.error('Failed to send gift purchase confirmation email', err); } return true; } private assertRedeemable(gift: Gift, memberStatus: string | null): Gift { const redeemableCheck = gift.checkRedeemable(memberStatus); if (!redeemableCheck.redeemable) { switch (redeemableCheck.reason) { case 'redeemed': throw new errors.BadRequestError({ message: tpl(errorMessages.giftAlreadyRedeemed), code: 'GIFT_REDEEMED' }); case 'consumed': throw new errors.BadRequestError({ message: tpl(errorMessages.giftConsumed), code: 'GIFT_CONSUMED' }); case 'expired': throw new errors.BadRequestError({ message: tpl(errorMessages.giftExpired), code: 'GIFT_EXPIRED' }); case 'refunded': throw new errors.BadRequestError({ message: tpl(errorMessages.giftRefunded), code: 'GIFT_REFUNDED' }); case 'paid-member': throw new errors.BadRequestError({ message: tpl(errorMessages.paidMember), code: 'GIFT_PAID_MEMBER' }); default: { const exhaustiveCheck: never = redeemableCheck.reason; throw new errors.InternalServerError({ message: `Unhandled redeem failure reason: ${exhaustiveCheck}` }); } } } return gift; } async getRedeemable(input: {token: string; memberStatus: string | null}): Promise { const gift = await this.deps.giftRepository.getByToken(input.token); if (!gift) { throw new errors.NotFoundError({message: tpl(errorMessages.giftNotFound)}); } this.assertRedeemable(gift, input.memberStatus); return this.serializeRedemption(gift); } async redeem(input: { token: string; memberId: string; transacting?: Knex.Transaction; newMember?: boolean; }): Promise { const run = async (transacting: Knex.Transaction) => { const {redeemed, member} = await this.redeemGift(input.token, input.memberId, { transacting, newMember: input.newMember }); const redemption = await this.serializeRedemption(redeemed); return {redeemed, member, redemption}; }; const {redeemed, member, redemption} = input.transacting ? await run(input.transacting) : await this.deps.giftRepository.transaction(run); const notify = async () => { try { const tier = await this.deps.tiersService.api.read(redeemed.tierId); if (!tier) { throw new errors.NotFoundError({message: `Tier not found: ${redeemed.tierId}`}); } await this.deps.staffServiceEmails.notifyGiftSubscriptionStarted({ memberId: member.id, memberEmail: member.get('email'), memberName: member.get('name'), tierName: tier.name, cadence: redeemed.cadence, duration: redeemed.duration, buyerEmail: redeemed.buyerEmail }); } catch (err) { logging.error('Failed to notify staff of gift redemption', err); } await this.deps.giftReminderScheduler.scheduleFor(redeemed); }; if (input.transacting) { // Only notify once the transaction has finished input.transacting.executionPromise.then(notify, () => {}); } else { await notify(); } return redemption; } private async redeemGift(token: string, memberId: string, options: {transacting: Knex.Transaction; newMember?: boolean}): Promise<{redeemed: Gift; member: MemberModel}> { const {transacting} = options; const member = await this.deps.memberRepository.get({id: memberId}, {transacting, forUpdate: true}); if (!member) { throw new errors.NotFoundError({message: `Member not found: ${memberId}`}); } const gift = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true}); if (!gift) { throw new errors.NotFoundError({message: tpl(errorMessages.giftNotFound)}); } if (options.newMember) { this.assertRedeemable(gift, null); } else { this.assertRedeemable(gift, member.get('status')); } const redeemed = gift.redeem({memberId}); await this.deps.memberRepository.update({ products: [{ id: redeemed.tierId, expiry_at: redeemed.consumesAt }], status: 'gift' }, {id: memberId, transacting}); await this.deps.giftRepository.update(redeemed, {transacting}); // Gift members receive the paid welcome email, as they receive access to paid content await this.deps.memberRepository.triggerMemberSignupAutomation( memberId, member.get('email'), 'paid', {transacting} ); return {redeemed, member}; } private async getActiveByMember(memberId: string, options: {transacting?: Knex.Transaction} = {}): Promise { if (!memberId) { return null; } return this.deps.giftRepository.getActiveByMember(memberId, options); } private async getActiveByMembers(memberIds: string[], options: {transacting?: Knex.Transaction} = {}): Promise> { if (!memberIds || memberIds.length === 0) { return new Map(); } return this.deps.giftRepository.getActiveByMembers(memberIds, options); } private getRemainingActiveDays(gift: Gift, now: Date = new Date()): number { if (!gift.isRedeemed() || !gift.consumesAt || gift.isConsumed()) { return 0; } const diffDays = Math.ceil((gift.consumesAt.getTime() - now.getTime()) / MS_PER_DAY); return Math.max(0, diffDays); } async preparePaidContinuation({ memberId, memberStatus }: { memberId: string; memberStatus: string; }): Promise { if (memberStatus !== 'gift') { throw new errors.BadRequestError({ message: 'Bad Request.', context: 'Member does not have an active gift subscription' }); } const gift = await this.getActiveByMember(memberId); if (!gift) { throw new errors.BadRequestError({ message: 'Bad Request.', context: 'No active gift subscription found for member' }); } const remainingDays = this.getRemainingActiveDays(gift); return { tierId: gift.tierId, cadence: gift.cadence, trialDays: remainingDays > 0 ? Math.min(remainingDays, 730) : null }; } async getMemberPresentations(memberIds: string[]): Promise> { const gifts = await this.getActiveByMembers(memberIds); const presentations = new Map(); for (const [memberId, gift] of gifts) { presentations.set(memberId, { cadence: gift.cadence, currency: gift.currency, amount: gift.amount }); } return presentations; } async getPreview(token: string): Promise { const gift = await this.deps.giftRepository.getByToken(token); if (!gift) { return null; } const tier = await this.deps.tiersService.api.read(gift.tierId); if (!tier) { throw new errors.NotFoundError({message: `Tier not found for gift: ${gift.token}`}); } const tierJSON = tier.toJSON(); return { cadence: gift.cadence, duration: gift.duration, tier: { id: tierJSON.id, name: tierJSON.name } }; } browsePurchaseEvents(options?: GiftEventBrowseOptions, filter?: unknown): Promise { return this.deps.giftRepository.browsePurchaseEvents(options, filter); } browseRedemptionEvents(options?: GiftEventBrowseOptions, filter?: unknown): Promise { return this.deps.giftRepository.browseRedemptionEvents(options, filter); } async reassignRedeemer(input: {giftId: string; memberId: string; transacting?: Knex.Transaction}): Promise { const {giftId, memberId} = input; const run = async (transacting: Knex.Transaction): Promise => { const gift = await this.deps.giftRepository.getById(giftId, {transacting, forUpdate: true}); if (!gift) { throw new errors.NotFoundError({message: tpl(errorMessages.giftNotFound)}); } if (gift.redeemerMemberId === memberId) { return gift; } const check = gift.checkReassignable(); if (!check.reassignable) { switch (check.reason) { case 'assigned': throw new errors.BadRequestError({message: tpl(errorMessages.giftAlreadyAssigned)}); case 'unredeemed': case 'consumed': case 'expired': case 'refunded': throw new errors.BadRequestError({message: tpl(errorMessages.giftInvalidReassignStatus)}); case 'missing-consumes-at': throw new errors.BadRequestError({message: tpl(errorMessages.giftMissingConsumesAt)}); default: { const exhaustiveCheck: never = check.reason; throw new errors.InternalServerError({ message: `Unhandled reassign failure reason: ${exhaustiveCheck}` }); } } } const member = await this.deps.memberRepository.get( {id: memberId}, {transacting, forUpdate: true} ); if (!member) { throw new errors.NotFoundError({message: `Member not found: ${memberId}`}); } const memberStatus = member.get('status'); if (memberStatus !== 'free' && memberStatus !== 'gift') { throw new errors.BadRequestError({message: tpl(errorMessages.giftInvalidReassignMember)}); } const existingActiveGift = await this.deps.giftRepository.getActiveByMember(memberId, {transacting}); if (existingActiveGift && existingActiveGift.token !== gift.token) { throw new errors.BadRequestError({message: tpl(errorMessages.giftMemberAlreadyHasGift)}); } const reassignedGift = gift.reassignRedeemer(memberId); await this.deps.memberRepository.update({ products: [{ id: reassignedGift.tierId, expiry_at: reassignedGift.consumesAt }], status: 'gift' }, {id: memberId, transacting}); await this.deps.giftRepository.update(reassignedGift, {transacting}); return reassignedGift; }; await (input.transacting ? run(input.transacting) : this.deps.giftRepository.transaction(run)); } async handlePaymentRefund({paymentIntentId}: {paymentIntentId: string}): Promise { const gift = await this.deps.giftRepository.getByPaymentIntentId(paymentIntentId); if (!gift) { return false; } const refunded = gift.refund(); if (!refunded) { return true; } await this.deps.giftRepository.transaction(async (transacting) => { await this.deps.giftRepository.update(refunded, {transacting}); if (gift.redeemerMemberId) { const member = await this.deps.memberRepository.get({id: gift.redeemerMemberId}, {transacting}); if (member?.get('status') === 'gift') { await this.deps.memberRepository.update({ products: [], status: 'free' }, {id: gift.redeemerMemberId, transacting}); } } }); return true; } async handlePaidSubscriptionActivation(memberId: string): Promise { const gift = await this.getActiveByMember(memberId); if (!gift) { return false; } return Boolean(await this.consume(gift.token)); } private async consume(token: string, options: {transacting?: Knex.Transaction} = {}): Promise { const run = async (transacting: Knex.Transaction) => { // Fetch with a row lock to prevent race conditions under concurrency const gift = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true}); if (!gift || gift.status !== 'redeemed') { return null; } const consumed = gift.consume(); if (!consumed) { return null; } await this.deps.giftRepository.update(consumed, {transacting}); return consumed; }; return options.transacting ? await run(options.transacting) : await this.deps.giftRepository.transaction(run); } async processConsumed(): Promise<{consumedCount: number; updatedMemberCount: number}> { const toConsume = await this.deps.giftRepository.findPendingConsumption(); if (toConsume.length === 0) { return {consumedCount: 0, updatedMemberCount: 0}; } let consumedCount = 0; let updatedMemberCount = 0; for (const gift of toConsume) { await this.deps.giftRepository.transaction(async (transacting) => { const consumed = await this.consume(gift.token, {transacting}); if (!consumed) { return; } const member = await this.deps.memberRepository.get({id: consumed.redeemerMemberId}, {transacting, forUpdate: true}); if (member && member.get('status') === 'gift') { await this.deps.memberRepository.update({ products: [], status: 'free' }, {id: consumed.redeemerMemberId, transacting}); updatedMemberCount += 1; } consumedCount += 1; }); } return {consumedCount, updatedMemberCount}; } async processExpired(): Promise<{expiredCount: number}> { const toExpire = await this.deps.giftRepository.findPendingExpiration(); if (toExpire.length === 0) { return {expiredCount: 0}; } let expiredCount = 0; for (const gift of toExpire) { await this.deps.giftRepository.transaction(async (transacting) => { // Re-fetch with a row lock to prevent races with concurrent redeems / refunds const locked = await this.deps.giftRepository.getByToken(gift.token, {transacting, forUpdate: true}); if (locked?.status !== 'purchased') { return; } const expired = locked.expire(); if (!expired) { return; } await this.deps.giftRepository.update(expired, {transacting}); expiredCount += 1; }); } return {expiredCount}; } async processReminders(): Promise<{remindedCount: number; skippedCount: number; failedCount: number}> { const now = new Date(); const toRemind = await this.deps.giftRepository.findPendingReminder({ now, reminderLeadMs: GIFT_REMINDER_LEAD_MS, reminderFloorMs: GIFT_REMINDER_FLOOR_MS }); if (toRemind.length === 0) { return {remindedCount: 0, skippedCount: 0, failedCount: 0}; } let remindedCount = 0; let skippedCount = 0; let failedCount = 0; for (const gift of toRemind) { try { const sent = await this.sendReminderForGift(gift.token); if (sent) { remindedCount += 1; } else { skippedCount += 1; } } catch (err) { logging.error(err); failedCount += 1; } } return {remindedCount, skippedCount, failedCount}; } private async sendReminderForGift(token: string): Promise { const gift = await this.deps.giftRepository.getByToken(token); if (!gift) { return false; } const tier = await this.deps.tiersService.api.read(gift.tierId); if (!tier) { throw new errors.NotFoundError({message: `Tier not found for gift: ${gift.tierId}`}); } const result = await this.deps.giftRepository.transaction(async (transacting): Promise => { const locked = await this.deps.giftRepository.getByToken(token, {transacting, forUpdate: true}); if (!locked) { return null; } if ( // Gift must still be active — a concurrent refund or early consume can happen // between `findPendingReminder` and this re-read. locked.status !== 'redeemed' // Idempotency guard: another path (rerun, scheduler) may already have sent. || locked.consumesSoonReminderSentAt !== null // Narrows `redeemerMemberId` from `string | null` to `string` — always set for redeemed gifts. || locked.redeemerMemberId === null // Narrows `consumesAt` from `Date | null` to `Date` — always set for redeemed gifts. || locked.consumesAt === null ) { return null; } const member = await this.deps.memberRepository.get( {id: locked.redeemerMemberId}, {transacting, forUpdate: true} ); // Record the reminder as sent before any skip or send below so we don't // re-try gifts with permanently unreachable redeemers on every poll. const reminded = locked.remind(); if (!reminded) { return null; } await this.deps.giftRepository.update(reminded, {transacting}); if (!member) { return null; } if (member.get('email_disabled')) { return null; } return { memberEmail: member.get('email'), memberName: member.get('name'), consumesAt: locked.consumesAt }; }); if (!result) { return false; } await this.deps.giftEmailService.sendReminder({ memberEmail: result.memberEmail, memberName: result.memberName, tierName: tier.name, consumesAt: result.consumesAt }); return true; } private async serializeRedemption(gift: Gift): Promise { const tier = await this.deps.tiersService.api.read(gift.tierId); if (!tier) { throw new errors.InternalServerError({ message: `Tier ${gift.tierId} not found for gift: ${gift.token}` }); } const tierJSON = tier.toJSON(); return { token: gift.token, cadence: gift.cadence, duration: gift.duration, currency: gift.currency, amount: gift.amount, expires_at: gift.expiresAt, consumes_at: gift.consumesAt, tier: { id: tierJSON.id, name: tierJSON.name, description: tierJSON.description, benefits: tierJSON.benefits } }; } }