Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -374,6 +375,7 @@ async function initServices() {
slackNotifications.init(),
mediaInliner.init(),
donationService.init(),
giftService.init(),
recommendationsService.init(),
statsService.init(),
explorePingService.init()
Expand Down
26 changes: 26 additions & 0 deletions ghost/core/core/server/models/gift.js
Original file line number Diff line number Diff line change
@@ -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)
};
1 change: 1 addition & 0 deletions ghost/core/core/server/services/gifts/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const GIFT_EXPIRY_DAYS = 365;
48 changes: 48 additions & 0 deletions ghost/core/core/server/services/gifts/gift-bookshelf-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {Gift} from './gift';

type BookshelfModel<T> = {
add(data: Partial<T>, unfilteredOptions?: unknown): Promise<T>;
findOne(data: Record<string, unknown>, unfilteredOptions?: unknown): Promise<T | null>;
};

type GiftBookshelfModel = BookshelfModel<Record<string, unknown>>;

export class GiftBookshelfRepository {
readonly #Model: GiftBookshelfModel;

constructor({GiftModel}: {GiftModel: GiftBookshelfModel}) {
this.#Model = GiftModel;
}

async existsByCheckoutSessionId(checkoutSessionId: string): Promise<boolean> {
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
});
}
}
27 changes: 27 additions & 0 deletions ghost/core/core/server/services/gifts/gift-service-wrapper.js
Original file line number Diff line number Diff line change
@@ -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;
63 changes: 63 additions & 0 deletions ghost/core/core/server/services/gifts/gift-service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<boolean> {
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a neat trick. I wonder if we should extend that to use the buyer email as source, instead of the stripe customer?

Example: buyer is "mike@ghost.org" → There is a free member with mike@ghost.org → attribute gift to that member?


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;
}
}
103 changes: 103 additions & 0 deletions ghost/core/core/server/services/gifts/gift.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
}
3 changes: 3 additions & 0 deletions ghost/core/core/server/services/gifts/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const GiftServiceWrapper = require('./gift-service-wrapper');

module.exports = new GiftServiceWrapper();
2 changes: 2 additions & 0 deletions ghost/core/core/server/services/stripe/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -60,6 +61,7 @@ module.exports = new StripeService({
}
},
donationService,
giftService,
staffService,
settingsCache
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ghost/core/core/server/services/stripe/stripe-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +54,7 @@ module.exports = class StripeService {
labs,
membersService,
donationService,
giftService,
staffService,
StripeWebhook,
settingsCache,
Expand Down Expand Up @@ -110,6 +112,9 @@ module.exports = class StripeService {
get donationRepository(){
return donationService.repository;
},
get giftService(){
return giftService.service;
},
Comment on lines +115 to +117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Verify boot/service init ordering for gifts vs stripe
fd -i "boot.js" ghost/core/core
rg -n -C4 "services|init|gifts|stripe" ghost/core/core/boot.js

# 2) Verify gifts export/init shape
fd -i "index.js" ghost/core/core/server/services/gifts
rg -n -C4 "module\\.exports|GiftServiceWrapper|init\\(" ghost/core/core/server/services/gifts/index.js
rg -n -C4 "class GiftServiceWrapper|service;|async init\\(" ghost/core/core/server/services/gifts/gift-service-wrapper.js

# 3) Verify call site assumes concrete service methods
rg -n -C4 "giftService|recordPurchase" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 19001


🏁 Script executed:

# Check when stripe service registers webhooks
rg -n -C5 "webhook|registerWebhook|handleEvent" ghost/core/core/server/services/stripe/stripe-service.js | head -60

# Check if webhooks are registered during init() or later
rg -n -C3 "registerWebhook|listen" ghost/core/core/server/services/stripe/index.js

# Verify the actual webhook event flow - when is the getter accessed?
rg -n -B5 -A5 "giftService.*recordPurchase|deps\.giftService" ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 3907


🏁 Script executed:

# Check when webhooks are actually registered/started in stripe init
rg -n -B10 -A10 "webhookManager\|WebhookManager\|webhooks.listen" ghost/core/core/server/services/stripe/index.js

# Check if there's any middleware or guard before webhook handling
rg -n "webhookController" ghost/core/core/server/services/stripe/stripe-service.js

# Check if giftService is checked for null/undefined before use
rg -n -B2 -A2 "giftService\." ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

# Check the webhook event dispatcher - does it await initialization?
fd -i "webhook-controller.js" ghost/core/core/server/services/stripe
rg -n -B5 -A10 "class WebhookController\|async.*handle\|handle.*event" ghost/core/core/server/services/stripe/webhook-controller.js | head -80

Repository: TryGhost/Ghost

Length of output: 504


🏁 Script executed:

# Get the full webhook controller file to understand webhook handling
cat -n ghost/core/core/server/services/stripe/webhook-controller.js | head -150

# Check how stripe service initializes and when webhooks start listening
cat -n ghost/core/core/server/services/stripe/index.js | head -100

Repository: TryGhost/Ghost

Length of output: 6143


🏁 Script executed:

# Get the stripe service init logic
cat -n ghost/core/core/server/services/stripe/service.js | head -200

# Also check when the webhook route is registered - look for router/app setup
rg -n "webhook\|stripe.*route" ghost/core/core/server/web -A3 -B3 | head -80

Repository: TryGhost/Ghost

Length of output: 3211


🏁 Script executed:

# Check how CheckoutSessionEventService is instantiated and when giftService is passed to it
rg -n -B5 -A5 "CheckoutSessionEventService" ghost/core/core/server/services/stripe/stripe-service.js

# Check the full checkout-session-event-service to see how it uses giftService
cat -n ghost/core/core/server/services/stripe/services/webhook/checkout-session-event-service.js

Repository: TryGhost/Ghost

Length of output: 15712


🏁 Script executed:

# Get lines 101-120 from stripe-service.js to see the full CheckoutSessionEventService instantiation
sed -n '101,120p' ghost/core/core/server/services/stripe/stripe-service.js

# Also get the complete constructor of StripeService to see when giftService is stored
sed -n '50,120p' ghost/core/core/server/services/stripe/stripe-service.js

Repository: TryGhost/Ghost

Length of output: 2795


Add a guard to ensure giftService is initialized before accessing its methods during webhook handling.

The race condition is real: stripe.init() completes and awaits on line 347 of boot.js, but giftService.init() runs concurrently in a Promise.all without awaiting. Stripe webhooks can arrive immediately after initialization, before giftService.service is populated. If a checkout.session.completed webhook with gift metadata arrives before giftService.init() finishes, the getter at lines 115-117 returns undefined, causing recordPurchase() to fail at runtime.

Either ensure giftService.init() is awaited before stripe webhooks become active, or add a defensive check in the getter to guard against uninitialized state.

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

In `@ghost/core/core/server/services/stripe/stripe-service.js` around lines 115 -
117, The getter giftService should defensively handle the case where
giftService.service is not yet initialized to avoid runtime failures in webhook
handling; update the giftService getter to check that the module-level
giftService and giftService.service are defined and, if not, either return a
safe no-op stub (implementing the methods used by recordPurchase) or throw a
clear error so callers can bail gracefully; reference the giftService getter,
giftService.init, stripe.init and the recordPurchase path that handles
checkout.session.completed webhooks when adding this guard.

get staffServiceEmails(){
return staffService.api.emails;
},
Expand Down
Loading
Loading