-
-
Notifications
You must be signed in to change notification settings - Fork 11.4k
Added webhook handler for gift subscription purchases #27169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const GIFT_EXPIRY_DAYS = 365; |
| 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 | ||
| }); | ||
| } | ||
| } |
| 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; |
| 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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; | ||
| } | ||
| } | ||
| 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 | ||
| }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| const GiftServiceWrapper = require('./gift-service-wrapper'); | ||
|
|
||
| module.exports = new GiftServiceWrapper(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| }, | ||
|
Comment on lines
+115
to
+117
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.jsRepository: 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.jsRepository: 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 -80Repository: 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 -100Repository: 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 -80Repository: 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.jsRepository: 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.jsRepository: TryGhost/Ghost Length of output: 2795 Add a guard to ensure The race condition is real: Either ensure 🤖 Prompt for AI Agents |
||
| get staffServiceEmails(){ | ||
| return staffService.api.emails; | ||
| }, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.