mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-04 02:01:58 -05:00
Improved docstrings in the Stripe service and added README.md (#22214)
no issue - No real code changes - Added README.md to stripe service as a starting point to document some of the more complex stripe related flows - Added typedefs and docstrings to most of the Stripe service code for improved type safety and clarity
This commit is contained in:
parent
5f465b14e6
commit
bc25569843
8 changed files with 333 additions and 41 deletions
63
ghost/stripe/README.md
Normal file
63
ghost/stripe/README.md
Normal file
|
@ -0,0 +1,63 @@
|
|||
# Stripe Service
|
||||
This package contains code for Ghost's Stripe integration. It interacts with Stripe's API and handles webhooks.
|
||||
|
||||
The main export of this package is the `StripeService` class. It includes a wrapper around the Stripe API and webhook handling logic. It is instantiated in Ghost's `core/server/services/stripe` service.
|
||||
|
||||
## Stripe API
|
||||
The `StripeAPI` class is a wrapper around the Stripe API. It is used by the `StripeService` class to interact with Stripe's API.
|
||||
|
||||
## Stripe Webhooks
|
||||
Ghost listens for Stripe webhooks to know when a customer has subscribed to a plan, when a subscription has been cancelled, when a payment has failed, etc.
|
||||
|
||||
Things to keep in mind when working with Stripe webhooks:
|
||||
- Webhooks can arrive out of order. `checkout.session.completed` webhooks may arrive before or after `customer.subscription.created` webhooks.
|
||||
- Webhooks can be received and processed in parallel, so you should not rely on the order of the webhooks to determine the order of operations.
|
||||
- Operations in Stripe almost always produce multiple events, increasing the likelihood of race conditions.
|
||||
|
||||
See Stripe's [Webhooks Guide](https://docs.stripe.com/webhooks) for more information.
|
||||
|
||||
### Webhook Manager
|
||||
This class is responsible for registering the webhook endpoints with Stripe, so Stripe knows where to send the webhooks.
|
||||
|
||||
### Webhook Controller
|
||||
This class is responsible for handling the webhook events. It accepts the webhook event payload and delegates it to the appropriate handler based on the event type.
|
||||
|
||||
### Events
|
||||
The Webhook Controller listens for the following events:
|
||||
- `customer.subscription.deleted`
|
||||
- `customer.subscription.updated`
|
||||
- `customer.subscription.created`
|
||||
- `invoice.payment_succeeded`
|
||||
- `checkout.session.completed`
|
||||
|
||||
|
||||
## Stripe Flows
|
||||
|
||||
### Checkout Session Flow: New Subscription
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Member as Member
|
||||
participant Portal
|
||||
participant Ghost
|
||||
participant Stripe
|
||||
|
||||
Member->>Portal: Signs up for a paid plan
|
||||
Portal->>Ghost: Create checkout session
|
||||
Ghost->>Stripe: Create checkout session
|
||||
Stripe-->>Ghost: Return session ID
|
||||
Ghost-->>Portal: Return session ID
|
||||
Portal->>Stripe: Redirect to checkout page
|
||||
Note over Portal: Member enters payment details in Stripe's secure portal
|
||||
Stripe-->>Portal: Redirect to success URL
|
||||
|
||||
par Webhook Events
|
||||
Stripe->>Ghost: customer.subscription.created
|
||||
Ghost->>Ghost: Upsert member and subscription
|
||||
Stripe->>Ghost: checkout.session.completed
|
||||
Ghost->>Ghost: Upsert member and subscription
|
||||
Stripe->>Ghost: customer.subscription.updated
|
||||
Ghost->>Ghost: Upsert member and subscription
|
||||
Stripe->>Ghost: invoice.payment_succeeded
|
||||
Ghost->>Ghost: Record payment
|
||||
end
|
||||
```
|
|
@ -28,6 +28,15 @@ const STRIPE_API_VERSION = '2020-08-27';
|
|||
* @typedef {import('stripe').Stripe.Plan} IPlan
|
||||
* @typedef {import('stripe').Stripe.Price} IPrice
|
||||
* @typedef {import('stripe').Stripe.WebhookEndpoint} IWebhookEndpoint
|
||||
* @typedef {import('stripe').Stripe.Coupon} ICoupon
|
||||
* @typedef {import('stripe').Stripe.CouponCreateParams} ICouponCreateParams
|
||||
* @typedef {import('stripe').Stripe.ProductCreateParams} IProductCreateParams
|
||||
* @typedef {import('stripe').Stripe.CustomerRetrieveParams} ICustomerRetrieveParams
|
||||
* @typedef {import('stripe').Stripe.Checkout.Session} ICheckoutSession
|
||||
* @typedef {import('stripe').Stripe.Checkout.SessionCreateParams} ICheckoutSessionCreateParams
|
||||
* @typedef {import('stripe').Stripe.SubscriptionRetrieveParams} ISubscriptionRetrieveParams
|
||||
* @typedef {import('stripe').Stripe.Subscription} ISubscription
|
||||
* @typedef {import('stripe').Stripe.Checkout.SessionCreateParams.PaymentMethodType} IPaymentMethodType
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -40,12 +49,14 @@ const STRIPE_API_VERSION = '2020-08-27';
|
|||
* @prop {string} checkoutSessionCancelUrl
|
||||
* @prop {string} checkoutSetupSessionSuccessUrl
|
||||
* @prop {string} checkoutSetupSessionCancelUrl
|
||||
* @prop {boolean} param.testEnv - indicates if the module is run in test environment (note, NOT the test mode)
|
||||
* @prop {boolean} testEnv - indicates if the module is run in test environment (note, NOT the test mode)
|
||||
*/
|
||||
|
||||
module.exports = class StripeAPI {
|
||||
/**
|
||||
* StripeAPI
|
||||
* @param {object} deps
|
||||
* @param {object} deps.labs
|
||||
*/
|
||||
constructor(deps) {
|
||||
/** @type {Stripe} */
|
||||
|
@ -54,6 +65,9 @@ module.exports = class StripeAPI {
|
|||
this.labs = deps.labs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {IPaymentMethodType[]|undefined}
|
||||
*/
|
||||
get PAYMENT_METHOD_TYPES() {
|
||||
if (this.labs.isSet('additionalPaymentMethods')) {
|
||||
return undefined;
|
||||
|
@ -62,20 +76,41 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the Stripe API is configured.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get configured() {
|
||||
return this._configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this package is running in a test environment (i.e. browser tests).
|
||||
*
|
||||
* Note: This is not the same as the Stripe API's test mode.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get testEnv() {
|
||||
return this._config.testEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Stripe API mode (test or live).
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get mode() {
|
||||
return this._testMode ? 'test' : 'live';
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the Stripe API.
|
||||
* - Instantiates the Stripe API client
|
||||
* - Sets the Stripe API mode
|
||||
* - Configures rate limiting buckets
|
||||
*
|
||||
* @param {IStripeAPIConfig} config
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
configure(config) {
|
||||
|
@ -103,7 +138,11 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* Create a new Stripe Coupon.
|
||||
*
|
||||
* @param {ICouponCreateParams} options
|
||||
*
|
||||
* @returns {Promise<ICoupon>}
|
||||
*/
|
||||
async createCoupon(options) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -113,6 +152,7 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retrieve the Stripe Product object by ID.
|
||||
* @param {string} id
|
||||
*
|
||||
* @returns {Promise<IProduct>}
|
||||
|
@ -125,8 +165,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.name
|
||||
* Create a new Stripe Product.
|
||||
* @param {IProductCreateParams} options
|
||||
*
|
||||
* @returns {Promise<IProduct>}
|
||||
*/
|
||||
|
@ -138,6 +178,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Price.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.product
|
||||
* @param {boolean} options.active
|
||||
|
@ -169,6 +211,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the Stripe Price object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} options
|
||||
* @param {boolean} [options.active]
|
||||
|
@ -187,6 +231,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the Stripe Product object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} options
|
||||
* @param {string} options.name
|
||||
|
@ -203,10 +249,13 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retrieve the Stripe Customer object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {import('stripe').Stripe.CustomerRetrieveParams} options
|
||||
* @param {ICustomerRetrieveParams} options
|
||||
*
|
||||
* @returns {Promise<ICustomer|IDeletedCustomer>}
|
||||
* @throws {Error}
|
||||
*/
|
||||
async getCustomer(id, options = {}) {
|
||||
debug(`getCustomer(${id}, ${JSON.stringify(options)})`);
|
||||
|
@ -227,6 +276,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Finds or creates a Stripe Customer for a Member.
|
||||
*
|
||||
* @deprecated
|
||||
* @param {any} member
|
||||
*
|
||||
|
@ -309,6 +360,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Customer.
|
||||
*
|
||||
* @param {import('stripe').Stripe.CustomerCreateParams} options
|
||||
*
|
||||
* @returns {Promise<ICustomer>}
|
||||
|
@ -327,6 +380,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the email address for a Stripe Customer.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} email
|
||||
*
|
||||
|
@ -346,7 +401,7 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* createWebhook.
|
||||
* Create a new Stripe Webhook Endpoint.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {import('stripe').Stripe.WebhookEndpointUpdateParams.EnabledEvent[]} events
|
||||
|
@ -371,6 +426,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delete a Stripe Webhook Endpoint by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
|
@ -389,6 +446,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a Stripe Webhook Endpoint by ID and URL.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {string} url
|
||||
* @param {import('stripe').Stripe.WebhookEndpointUpdateParams.EnabledEvent[]} events
|
||||
|
@ -415,7 +474,7 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* parseWebhook.
|
||||
* Parse a Stripe Webhook event.
|
||||
*
|
||||
* @param {string} body
|
||||
* @param {string} signature
|
||||
|
@ -436,6 +495,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Checkout Session for a new subscription.
|
||||
*
|
||||
* @param {string} priceId
|
||||
* @param {ICustomer} customer
|
||||
*
|
||||
|
@ -447,7 +508,7 @@ module.exports = class StripeAPI {
|
|||
* @param {number} options.trialDays
|
||||
* @param {string} [options.coupon]
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Checkout.Session>}
|
||||
* @returns {Promise<ICheckoutSession>}
|
||||
*/
|
||||
async createCheckoutSession(priceId, customer, options) {
|
||||
const metadata = options.metadata || undefined;
|
||||
|
@ -518,14 +579,17 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Checkout Session for a donation.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {Object.<String, any>} options.metadata
|
||||
* @param {string} options.priceId
|
||||
* @param {string} options.successUrl
|
||||
* @param {string} options.cancelUrl
|
||||
* @param {string} [options.customer]
|
||||
* @param {Object.<String, any>} options.metadata
|
||||
* @param {ICustomer} [options.customer]
|
||||
* @param {string} [options.customerEmail]
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Checkout.Session>}
|
||||
* @returns {Promise<ICheckoutSession>}
|
||||
*/
|
||||
async createDonationCheckoutSession({priceId, successUrl, cancelUrl, metadata, customer, customerEmail}) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -589,12 +653,14 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Checkout Setup Session.
|
||||
*
|
||||
* @param {ICustomer} customer
|
||||
* @param {object} options
|
||||
* @param {string} options.successUrl
|
||||
* @param {string} options.cancelUrl
|
||||
* @param {string} options.currency - 3-letter ISO code in lowercase, e.g. `usd`
|
||||
* @returns {Promise<import('stripe').Stripe.Checkout.Session>}
|
||||
* @returns {Promise<ICheckoutSession>}
|
||||
*/
|
||||
async createCheckoutSetupSession(customer, options) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -612,23 +678,29 @@ module.exports = class StripeAPI {
|
|||
|
||||
// Note: this is required for dynamic payment methods
|
||||
// https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-currency
|
||||
// @ts-ignore
|
||||
currency: this.labs.isSet('additionalPaymentMethods') ? options.currency : undefined
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Stripe public key.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getPublicKey() {
|
||||
return this._config.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* getPrice
|
||||
* Retrieve the Stripe Price object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} options
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Price>}
|
||||
* @returns {Promise<IPrice>}
|
||||
*/
|
||||
async getPrice(id, options = {}) {
|
||||
debug(`getPrice(${id}, ${JSON.stringify(options)})`);
|
||||
|
@ -637,12 +709,12 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* getSubscription.
|
||||
* Retrieve the Stripe Subscription object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {import('stripe').Stripe.SubscriptionRetrieveParams} options
|
||||
* @param {ISubscriptionRetrieveParams} options
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async getSubscription(id, options = {}) {
|
||||
debug(`getSubscription(${id}, ${JSON.stringify(options)})`);
|
||||
|
@ -658,11 +730,11 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* cancelSubscription.
|
||||
* Cancel the Stripe Subscription by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async cancelSubscription(id) {
|
||||
debug(`cancelSubscription(${id})`);
|
||||
|
@ -678,10 +750,12 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Cancel the Stripe Subscription at the end of the current period by ID.
|
||||
*
|
||||
* @param {string} id - The ID of the Subscription to modify
|
||||
* @param {string} [reason=''] - The user defined cancellation reason
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async cancelSubscriptionAtPeriodEnd(id, reason = '') {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -695,9 +769,11 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Continue the Stripe Subscription at the end of the current period by ID.
|
||||
*
|
||||
* @param {string} id - The ID of the Subscription to modify
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async continueSubscriptionAtPeriodEnd(id) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -711,9 +787,11 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove the coupon from the Stripe Subscription by ID.
|
||||
*
|
||||
* @param {string} id - The ID of the subscription to remove coupon from
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async removeCouponFromSubscription(id) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -724,6 +802,9 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the price of the Stripe SubscriptionItem by Subscription ID,
|
||||
* SubscriptionItem ID, and Price ID.
|
||||
*
|
||||
* @param {string} subscriptionId - The ID of the Subscription to modify
|
||||
* @param {string} id - The ID of the SubscriptionItem
|
||||
* @param {string} price - The ID of the new Price
|
||||
|
@ -731,7 +812,7 @@ module.exports = class StripeAPI {
|
|||
* @param {('always_invoice'|'create_prorations'|'none')} [options.prorationBehavior='always_invoice'] - The proration behavior to use. See [Stripe docs](https://docs.stripe.com/api/subscriptions/update#update_subscription-proration_behavior) for more info
|
||||
* @param {string} [options.cancellationReason=null] - The user defined cancellation reason
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async updateSubscriptionItemPrice(subscriptionId, id, price, options = {}) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -750,10 +831,12 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a new Stripe Subscription for a Customer by ID and Price ID.
|
||||
*
|
||||
* @param {string} customer - The ID of the Customer to create the subscription for
|
||||
* @param {string} price - The ID of the new Price
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async createSubscription(customer, price) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -765,6 +848,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retrieve the Stripe SetupIntent object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {import('stripe').Stripe.SetupIntentRetrieveParams} options
|
||||
*
|
||||
|
@ -776,6 +861,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Attach a PaymentMethod to a Customer
|
||||
*
|
||||
* @param {string} customer
|
||||
* @param {string} paymentMethod
|
||||
*
|
||||
|
@ -788,6 +875,8 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Retrieve the Stripe PaymentMethod object by ID.
|
||||
*
|
||||
* @param {string} id
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.PaymentMethod|null>}
|
||||
|
@ -803,10 +892,12 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update the default PaymentMethod for a Subscription.
|
||||
*
|
||||
* @param {string} subscription
|
||||
* @param {string} paymentMethod
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async updateSubscriptionDefaultPaymentMethod(subscription, paymentMethod) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
@ -816,9 +907,11 @@ module.exports = class StripeAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Cancel the trial for a Stripe Subscription by ID.
|
||||
*
|
||||
* @param {string} id - The ID of the subscription to cancel the trial for
|
||||
*
|
||||
* @returns {Promise<import('stripe').Stripe.Subscription>}
|
||||
* @returns {Promise<ISubscription>}
|
||||
*/
|
||||
async cancelSubscriptionTrial(id) {
|
||||
await this._rateLimitBucket.throttle();
|
||||
|
|
|
@ -8,7 +8,42 @@ const SubscriptionEventService = require('./services/webhook/SubscriptionEventSe
|
|||
const InvoiceEventService = require('./services/webhook/InvoiceEventService');
|
||||
const CheckoutSessionEventService = require('./services/webhook/CheckoutSessionEventService');
|
||||
|
||||
/**
|
||||
* @typedef {object} IStripeServiceConfig
|
||||
* @prop {string} secretKey The Stripe secret key
|
||||
* @prop {string} publicKey The Stripe publishable key
|
||||
* @prop {boolean} enablePromoCodes Whether to enable promo codes
|
||||
* @prop {boolean} enableAutomaticTax Whether to enable automatic tax
|
||||
* @prop {string} checkoutSessionSuccessUrl The URL to redirect to after successful checkout
|
||||
* @prop {string} checkoutSessionCancelUrl The URL to redirect to if checkout is cancelled
|
||||
* @prop {string} checkoutSetupSessionSuccessUrl The URL to redirect to after successful setup session
|
||||
* @prop {string} checkoutSetupSessionCancelUrl The URL to redirect to if setup session is cancelled
|
||||
* @prop {boolean} testEnv Whether this is a test environment
|
||||
* @prop {string} webhookSecret The Stripe webhook secret
|
||||
* @prop {string} webhookHandlerUrl The URL to handle Stripe webhooks
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `StripeService` contains the core logic for Ghost's Stripe integration.
|
||||
|
||||
*/
|
||||
module.exports = class StripeService {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {*} deps.labs
|
||||
* @param {*} deps.membersService
|
||||
* @param {*} deps.donationService
|
||||
* @param {*} deps.staffService
|
||||
* @param {import('./WebhookManager').StripeWebhook} deps.StripeWebhook
|
||||
* @param {object} deps.models
|
||||
* @param {object} deps.models.Product
|
||||
* @param {object} deps.models.StripePrice
|
||||
* @param {object} deps.models.StripeCustomerSubscription
|
||||
* @param {object} deps.models.StripeProduct
|
||||
* @param {object} deps.models.MemberStripeCustomer
|
||||
* @param {object} deps.models.Offer
|
||||
* @param {object} deps.models.Settings
|
||||
*/
|
||||
constructor({
|
||||
labs,
|
||||
membersService,
|
||||
|
@ -112,6 +147,10 @@ module.exports = class StripeService {
|
|||
DomainEvents.dispatch(StripeLiveDisabledEvent.create({message: 'Stripe Live Mode Disabled'}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the Stripe API and registers the webhook with Stripe
|
||||
* @param {IStripeServiceConfig} config
|
||||
*/
|
||||
async configure(config) {
|
||||
this.api.configure({
|
||||
secretKey: config.secretKey,
|
||||
|
|
|
@ -22,13 +22,16 @@ module.exports = class WebhookController {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a Stripe webhook event.
|
||||
* - Parses the webhook event
|
||||
* - Delegates the event to the appropriate handler
|
||||
* - Returns a 200 response to Stripe to confirm receipt of the event, or an error response if the event is not handled or if an error occurs
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handle(req, res) {
|
||||
// if (!apiService.configured) {
|
||||
// logging.error(`Stripe not configured, not handling webhook`);
|
||||
// res.writeHead(400);
|
||||
// return res.end();
|
||||
// }
|
||||
|
||||
if (!req.body || !req.headers['stripe-signature']) {
|
||||
res.writeHead(400);
|
||||
return res.end();
|
||||
|
@ -55,7 +58,10 @@ module.exports = class WebhookController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Accepts a webhook's event payload and delegates it to the appropriate handler based on the event type
|
||||
* @private
|
||||
* @param {import('stripe').Stripe.Event} event
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleEvent(event) {
|
||||
if (!this.handlers[event.type]) {
|
||||
|
@ -66,6 +72,8 @@ module.exports = class WebhookController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delegates any `customer.subscription.*` events to the `subscriptionEventService`
|
||||
* @param {import('stripe').Stripe.Subscription} subscription
|
||||
* @private
|
||||
*/
|
||||
async subscriptionEvent(subscription) {
|
||||
|
@ -73,6 +81,7 @@ module.exports = class WebhookController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delegates any `invoice.*` events to the `invoiceEventService`
|
||||
* @param {import('stripe').Stripe.Invoice} invoice
|
||||
* @private
|
||||
*/
|
||||
|
@ -81,6 +90,8 @@ module.exports = class WebhookController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delegates any `checkout.session.*` events to the `checkoutSessionEventService`
|
||||
* @param {import('stripe').Stripe.Checkout.Session} session
|
||||
* @private
|
||||
*/
|
||||
async checkoutSessionEvent(session) {
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
/**
|
||||
* @typedef {import('stripe').Stripe.WebhookEndpointCreateParams.EnabledEvent} WebhookEvent
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('stripe').Stripe.WebhookEndpoint} Webhook
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./StripeAPI')} StripeAPI
|
||||
*/
|
||||
|
||||
|
@ -18,7 +11,7 @@
|
|||
|
||||
/**
|
||||
* @typedef {object} StripeWebhook
|
||||
* @prop {(data: StripeWebhookModel) => Promise<StripeWebhookModel>} save
|
||||
* @prop {(data: StripeWebhookModel) => Promise<void>} save
|
||||
* @prop {() => Promise<StripeWebhookModel>} get
|
||||
*/
|
||||
|
||||
|
@ -57,6 +50,8 @@ module.exports = class WebhookManager {
|
|||
];
|
||||
|
||||
/**
|
||||
* Deletes the Stripe Webhook Endpoint and saves null values for the webhook ID and secret.
|
||||
*
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async stop() {
|
||||
|
@ -79,6 +74,11 @@ module.exports = class WebhookManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Stripe Webhook Endpoint and saves the webhook ID and secret.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async start() {
|
||||
if (this.mode !== 'network') {
|
||||
return;
|
||||
|
@ -96,6 +96,7 @@ module.exports = class WebhookManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Configures the Stripe Webhook Manager.
|
||||
* @param {object} config
|
||||
* @param {string} [config.webhookSecret] An optional webhook secret for use with stripe-cli, passing this will ensure a webhook is not created in Stripe
|
||||
* @param {string} config.webhookHandlerUrl The URL which the Webhook should hit
|
||||
|
@ -111,13 +112,17 @@ module.exports = class WebhookManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Setup a new Stripe Webhook Endpoint.
|
||||
* - If the webhook exists, delete it and create a new one
|
||||
* - If the webhook does not exist, create a new one
|
||||
*
|
||||
* @param {string} [id]
|
||||
* @param {string} [secret]
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.forceCreate]
|
||||
* @param {boolean} [opts.skipDelete]
|
||||
*
|
||||
* @returns {Promise<Webhook>}
|
||||
* @returns {Promise<{id: string, secret: string}>}
|
||||
*/
|
||||
async setupWebhook(id, secret, opts = {}) {
|
||||
if (!id || !secret || opts.forceCreate) {
|
||||
|
@ -158,6 +163,8 @@ module.exports = class WebhookManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse a Stripe Webhook event.
|
||||
*
|
||||
* @param {string} body
|
||||
* @param {string} signature
|
||||
* @returns {import('stripe').Stripe.Event}
|
||||
|
|
|
@ -3,12 +3,39 @@ const _ = require('lodash');
|
|||
const errors = require('@tryghost/errors');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
/**
|
||||
* Handles `checkout.session.completed` webhook events
|
||||
*
|
||||
* The `checkout.session.completed` event is triggered when a customer completes a checkout session.
|
||||
*
|
||||
* It is triggered for the following scenarios:
|
||||
* - Subscription
|
||||
* - Donation
|
||||
* - Setup intent
|
||||
*
|
||||
* This service delegates the event to the appropriate handler based on the session mode and metadata.
|
||||
*
|
||||
* The `session` payload can be found here: https://docs.stripe.com/api/checkout/sessions/object
|
||||
*/
|
||||
module.exports = class CheckoutSessionEventService {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('../StripeAPI')} deps.api
|
||||
* @param {object} deps.memberRepository
|
||||
* @param {object} deps.donationRepository
|
||||
* @param {object} deps.staffServiceEmails
|
||||
* @param {function} deps.sendSignupEmail
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.api = deps.api;
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `checkout.session.completed` event
|
||||
* Delegates to the appropriate handler based on the session mode and metadata
|
||||
* @param {import('stripe').Stripe.Checkout.Session} session
|
||||
*/
|
||||
async handleEvent(session) {
|
||||
if (session.mode === 'setup') {
|
||||
await this.handleSetupEvent(session);
|
||||
|
@ -23,6 +50,10 @@ module.exports = class CheckoutSessionEventService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `checkout.session.completed` event for a donation
|
||||
* @param {import('stripe').Stripe.Checkout.Session} session
|
||||
*/
|
||||
async handleDonationEvent(session) {
|
||||
const donationField = session.custom_fields?.find(obj => obj?.key === 'donation_message');
|
||||
const donationMessage = donationField?.text?.value ? donationField.text.value : null;
|
||||
|
@ -54,6 +85,13 @@ module.exports = class CheckoutSessionEventService {
|
|||
await staffServiceEmails.notifyDonationReceived({donationPaymentEvent: data});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `checkout.session.completed` event for a setup intent
|
||||
*
|
||||
* This is used when a customer adds or changes their payment method outside
|
||||
* of the normal subscription flow.
|
||||
* @param {import('stripe').Stripe.Checkout.Session} session
|
||||
*/
|
||||
async handleSetupEvent(session) {
|
||||
const setupIntent = await this.api.getSetupIntent(session.setup_intent);
|
||||
|
||||
|
@ -119,6 +157,10 @@ module.exports = class CheckoutSessionEventService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `checkout.session.completed` event for a subscription
|
||||
* @param {import('stripe').Stripe.Checkout.Session} session
|
||||
*/
|
||||
async handleSubscriptionEvent(session) {
|
||||
const customer = await this.api.getCustomer(session.customer, {
|
||||
expand: ['subscriptions.data.default_payment_method']
|
||||
|
|
|
@ -1,11 +1,29 @@
|
|||
const errors = require('@tryghost/errors');
|
||||
// const _ = require('lodash');
|
||||
|
||||
/**
|
||||
* Handles `invoice.payment_succeeded` webhook events
|
||||
*
|
||||
* The `invoice.payment_succeeded` event is triggered when a customer's payment succeeds.
|
||||
*/
|
||||
module.exports = class InvoiceEventService {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {object} deps.api
|
||||
* @param {object} deps.memberRepository
|
||||
* @param {object} deps.eventRepository
|
||||
* @param {object} deps.productRepository
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `invoice.payment_succeeded` event
|
||||
*
|
||||
* Inserts a payment event into the database
|
||||
* @param {import('stripe').Stripe.Invoice} invoice
|
||||
*/
|
||||
async handleInvoiceEvent(invoice) {
|
||||
const {api, memberRepository, eventRepository, productRepository} = this.deps;
|
||||
|
||||
|
|
|
@ -1,10 +1,29 @@
|
|||
const errors = require('@tryghost/errors');
|
||||
const _ = require('lodash');
|
||||
|
||||
/**
|
||||
* Handles `customer.subscription.*` webhook events
|
||||
*
|
||||
* The `customer.subscription.*` events are triggered when a customer's subscription status changes.
|
||||
*
|
||||
* This service is responsible for handling these events and updating the subscription status in Ghost,
|
||||
* although it mostly delegates the responsibility to the `MemberRepository`.
|
||||
*/
|
||||
module.exports = class SubscriptionEventService {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('../../repositories/MemberRepository')} deps.memberRepository
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `customer.subscription.*` event
|
||||
*
|
||||
* Looks up the member by the Stripe customer ID and links the subscription to the member.
|
||||
* @param {import('stripe').Stripe.Subscription} subscription
|
||||
*/
|
||||
async handleSubscriptionEvent(subscription) {
|
||||
const subscriptionPriceData = _.get(subscription, 'items.data');
|
||||
if (!subscriptionPriceData || subscriptionPriceData.length !== 1) {
|
||||
|
|
Loading…
Add table
Reference in a new issue