From a3c80f8d2cd778a9a3fde58fa6e9874187f5614c Mon Sep 17 00:00:00 2001 From: theld <lars@adornis.de> Date: Tue, 29 Apr 2025 13:37:42 +0000 Subject: [PATCH 1/3] feat!: allow for multiple stripe accounts --- modules/payments/README.md | 33 +++++ modules/payments/db/payments.ts | 17 ++- modules/payments/server/checkout.ts | 36 ++++-- modules/payments/server/customer.ts | 7 +- modules/payments/server/payment.ts | 13 +- modules/payments/server/products.ts | 21 ++- modules/payments/server/refund.ts | 3 +- modules/payments/server/router-helper.ts | 2 +- modules/payments/server/router.ts | 148 ++------------------- modules/payments/server/stripe-client.ts | 16 ++- modules/payments/server/stripeRouter.ts | 149 ++++++++++++++++++++++ modules/payments/server/types.ts | 13 ++ modules/payments/server/webhookHandler.ts | 89 +++++++------ 13 files changed, 334 insertions(+), 213 deletions(-) create mode 100644 modules/payments/server/stripeRouter.ts create mode 100644 modules/payments/server/types.ts diff --git a/modules/payments/README.md b/modules/payments/README.md index 8dac3c721a..d8bd8ff1e6 100644 --- a/modules/payments/README.md +++ b/modules/payments/README.md @@ -38,6 +38,8 @@ If customer doesn't want to share account access, we need: ### Env Vars +The default convenience function `paymentsRouter` uses the following env vars: + - STRIPE_PUBLISHABLE_KEY -> stripe credentials - STRIPE_PRIVATE_KEY -> stripe credentials - STRIPE_WEBHOOK_SECRET -> webhook secret to verify requests to the webhook endpoint. You can get this at the webhook page in stripe @@ -80,6 +82,36 @@ You can also pass two more paramters: - SubscriptionClass, which also has to extend `Payment`, to be used for subscription payments. - InvoiceClass, has to extend `Invoice` and is used to generate Invoices. This has a `createFromStripeInvoice` static method, which can be overwritten to modify what info is copied from stripes invoices. Stripe automatically generates invoices for subscriptions, if you also want to generate invoices for one-time payments, pass `createInvoice: true` to the `payment.pay` method. +#### Multiple stripe accounts + +Some usecases require the use of a different stripe account depending on the product bought, or the user buying the product (or a random number for what i care...). The `paymentsRouter` function used above is a convenience function that creates a stripe router for a single stripe account via the credentials provided through env vars. +If you need to use multiple stripe accounts, you can create a stripe router for each account and use them in your express server. You will have to provide the stripe credentials for each router yourself if you use this approach. + +```ts +expressServer.use( + stripeRouter({ + PaymentsClass: YourPaymentExtension, + scope: 'scopeA', + stripePrivateKey: 'stripePrivateKey', + stripePublishableKey: 'stripePublishableKey', + stripeWebhookSecret: 'stripeWebhookSecret', + }), +); +expressServer.use( + stripeRouter({ + PaymentsClass: YourPaymentExtension, + scope: 'scopeB', + stripePrivateKey: 'stripePrivateKey', + stripePublishableKey: 'stripePublishableKey', + stripeWebhookSecret: 'stripeWebhookSecret', + }), +); +``` + +The `scope` parameter is used to create a unique path for the stripe router. Default scope if not provided is `'default'`. You cannot use the same scope for multiple stripe routers. + +The scope can also be provided to the Payment.pay function described below to target a specific router/stripe account. + ### Client ```ts @@ -115,6 +147,7 @@ await new Payment({}).pay({ mode: 'payment', // payment for one-time, subscription for subscriptions. createInvoice: false, // can be set to trigger invoice creation. savePaymentMethod: true, // saves the payment method on the customer + scope: 'scopeA', // optionally provide a scope to target a specific stripe router. If no scope is provided, the default scope is used. }); ``` diff --git a/modules/payments/db/payments.ts b/modules/payments/db/payments.ts index 215ab23d71..c429205b82 100644 --- a/modules/payments/db/payments.ts +++ b/modules/payments/db/payments.ts @@ -1,4 +1,5 @@ import { A } from '@adornis/base/env-info.js'; +import type { Maybe } from '@adornis/base/utilTypes.js'; import { Float, ID, Int } from '@adornis/baseql/baseqlTypes.js'; import { Arg, Entity, Field, Mutation, Query, Subscription } from '@adornis/baseql/decorators.js'; import { AdornisEntity } from '@adornis/baseql/entities/adornisEntity.js'; @@ -9,14 +10,14 @@ import { context, publishFilter } from '@adornis/baseql/server/context.js'; import { runInServerContext } from '@adornis/baseql/server/server.js'; import { selectionSet, type BaseQLSelectionSet } from '@adornis/baseql/utils/queryGeneration.js'; import { goTo } from '@adornis/router/client/open-href.js'; -import { EntityPatch, Versioned } from '@adornis/versioned-entity/db/versioned-mongo-entity.js'; +import { Versioned, type EntityPatch } from '@adornis/versioned-entity/db/versioned-mongo-entity.js'; import { DateTime } from 'luxon'; import { from } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import type Stripe from 'stripe'; import { getSessionUrlForNewPaymentInstance } from '../server/checkout.js'; import { cancelPayment, refundPayment } from '../server/payment.js'; -import { type BasicProduct } from './basic-product.js'; +import type { BasicProduct } from './basic-product.js'; import { ChosenProduct } from './chosenProduct.js'; import { Invoice } from './invoice.js'; import { PaymentError } from './paymentError.js'; @@ -32,6 +33,7 @@ export interface CreatePaymentOptions { product: BasicProduct; priceID: string; }>; + scope?: string; mode?: Stripe.Checkout.Session.Mode; paymentMethods?: Array<Stripe.Checkout.SessionCreateParams.PaymentMethodType> | null; successURL?: string; @@ -53,6 +55,7 @@ export class PayOptions extends AdornisEntity { } @Field(type => String) successURL!: string; @Field(type => String) cancelURL!: string; + @Field(type => String) scope?: Maybe<string>; @Field(type => [String]) paymentMethods!: Array<Stripe.Checkout.SessionCreateParams.PaymentMethodType> | null; @Field(type => Boolean) createInvoice?: boolean; @Field(type => Boolean) savePaymentMethod?: boolean; @@ -121,6 +124,9 @@ export class Payment extends MongoEntity { @Field(type => String) paymentType!: Stripe.Checkout.SessionCreateParams.PaymentMethodType; + @Field(type => String) + scope?: Maybe<string>; + @Field(type => String) cancelReason?: string; @@ -185,6 +191,7 @@ export class Payment extends MongoEntity { async createPaymentLink({ products, mode = 'payment', + scope, paymentMethods = ['card', 'paypal'], successURL = '/product/success', cancelURL = '/product/cancel', @@ -220,6 +227,7 @@ export class Payment extends MongoEntity { new PayOptions({ successURL, cancelURL, + scope, paymentMethods, sellerID, createInvoice, @@ -265,8 +273,9 @@ export class Payment extends MongoEntity { return async (gqlFields: void) => { if (context.serverContext) throw new Error('the server cannot buy anything'); const userID = context.userID; - return runInServerContext(async () => + return runInServerContext(() => getSessionUrlForNewPaymentInstance({ + scope: payOptions.scope ?? 'default', paymentInstance, products, userID, @@ -293,6 +302,7 @@ export class Payment extends MongoEntity { const payment = await this.getByID<Payment>(paymentId)({ _id: 1, _class: 1, + scope: 1, processorInfo: ProcessorInfo.allFields, }); if (!payment) throw new Error('Payment not found'); @@ -311,6 +321,7 @@ export class Payment extends MongoEntity { const payment = await this.getByID<Payment>(paymentId)({ _id: 1, _class: 1, + scope: 1, processorInfo: ProcessorInfo.allFields, status: 1, }); diff --git a/modules/payments/server/checkout.ts b/modules/payments/server/checkout.ts index 508b5fc39b..9b154b0a9a 100644 --- a/modules/payments/server/checkout.ts +++ b/modules/payments/server/checkout.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ +/* eslint-disable @typescript-eslint/only-throw-error */ import { A } from '@adornis/base/env-info.js'; import { logger } from '@adornis/base/logging.js'; import type { EntityData } from '@adornis/baseql/entities/types.js'; @@ -13,10 +15,12 @@ import { PaymentError } from '../db/paymentError.js'; import { StatusUpdate, type Payment } from '../db/payments.js'; import { createPayment } from './payment.js'; import { ensureStripeProduct } from './products.js'; -import { STRIPE_CHECKOUT_REDIRECT_PATH } from './router.js'; -import { stripe } from './stripe-client.js'; +import { getStripeClient } from './stripe-client.js'; +import { STRIPE_CHECKOUT_REDIRECT_PATH } from './stripeRouter.js'; export const createPaymentCheckoutSession = async ({ + stripe, + scope, paymentInstance, paymentMethods, successURL, @@ -27,6 +31,8 @@ export const createPaymentCheckoutSession = async ({ collectTaxID, automaticTax, }: { + stripe: Stripe; + scope: string; paymentInstance: Payment; paymentMethods?: Array<Stripe.Checkout.SessionCreateParams.PaymentMethodType>; successURL: string; @@ -38,9 +44,9 @@ export const createPaymentCheckoutSession = async ({ automaticTax?: boolean; }) => { for (const product of paymentInstance.products) { - await ensureStripeProduct(product.product); + await ensureStripeProduct(stripe, product.product); } - const line_items = paymentInstance.products.map(product => { + const lineItems = paymentInstance.products.map(product => { const data = { price: product.price.stripeID, // price_data: { @@ -61,7 +67,7 @@ export const createPaymentCheckoutSession = async ({ payment_method_types: paymentMethods, success_url: A.absoluteUrl( - STRIPE_CHECKOUT_REDIRECT_PATH, + STRIPE_CHECKOUT_REDIRECT_PATH(scope), new URLSearchParams({ redirect: successURL, paymentsID: paymentInstance._id, @@ -71,7 +77,7 @@ export const createPaymentCheckoutSession = async ({ ) + '&token={CHECKOUT_SESSION_ID}', cancel_url: A.absoluteUrl( - STRIPE_CHECKOUT_REDIRECT_PATH, + STRIPE_CHECKOUT_REDIRECT_PATH(scope), new URLSearchParams({ redirect: cancelURL, paymentsID: paymentInstance._id, @@ -80,7 +86,7 @@ export const createPaymentCheckoutSession = async ({ }), ) + '&token={CHECKOUT_SESSION_ID}', mode: paymentInstance.mode, - line_items, + line_items: lineItems, client_reference_id: paymentInstance._id, }; if (paymentInstance.mode === 'subscription') { @@ -126,6 +132,7 @@ export const createPaymentCheckoutSession = async ({ }; export const getSessionUrlForNewPaymentInstance = async ({ + scope, paymentInstance, products, userID, @@ -138,6 +145,7 @@ export const getSessionUrlForNewPaymentInstance = async ({ collectTaxID, requireBillingAddress, }: { + scope: string; paymentInstance: Payment; products: ChosenProduct[]; userID: any; @@ -168,10 +176,11 @@ export const getSessionUrlForNewPaymentInstance = async ({ ); }; + const stripe = getStripeClient(scope); + // TODO this needs more thought, as the only info really required to buy a product is: product._id, product._class and the priceID of the selected price // TODO maybe change api of ChosenProduct to reflect that - for (let i = 0; i < products.length; i++) { - const product = products[i]!; + for (const product of products) { const productCollection = await getRawCollection<any>( (product.product.constructor as typeof BasicProduct)._collectionName, ); @@ -184,6 +193,7 @@ export const getSessionUrlForNewPaymentInstance = async ({ } const { err: instanceCreateError, id: paymentInstanceID } = await createPayment({ + scope, paymentInstance, products, userID, @@ -197,6 +207,8 @@ export const getSessionUrlForNewPaymentInstance = async ({ let sessionCreateError: Error | null = null; try { stripeSession = await createPaymentCheckoutSession({ + stripe, + scope, paymentInstance, paymentMethods: paymentMethods ?? undefined, successURL, @@ -234,15 +246,18 @@ export const getSessionUrlForNewPaymentInstance = async ({ const completionLocks: Record<string, string> = {}; export const successfullyCompleteSession = async ({ + stripe, stripeSessionID, PaymentClass, paymentID, }: { + stripe: Stripe; stripeSessionID: string; PaymentClass: typeof Payment; paymentID: string; }): Promise<string> => { logger.debug({ stripeSessionID, lock: completionLocks[stripeSessionID] }, 'confirming stripe payment'); + // this payment is already in the process of being confirmed // lock is required since the session is completed by both the redirected user via the paymentsRouter and potentially a webhook sent by stripe. let taskID = completionLocks[stripeSessionID]; @@ -284,7 +299,7 @@ export const successfullyCompleteSession = async ({ expand: ['payment_intent', 'subscription'], }); if (session.mode === 'payment') await completePaymentSession(session, paymentInstance); - if (session.mode === 'subscription') await completeSubscriptionSession(session, paymentInstance); + if (session.mode === 'subscription') await completeSubscriptionSession(stripe, session, paymentInstance); await paymentInstance.save(); logger.info( { orderID: paymentInstance._id, status: session.status }, @@ -332,6 +347,7 @@ const completePaymentSession = async ( }; const completeSubscriptionSession = async ( + stripe: Stripe, session: Stripe.Response<Stripe.Checkout.Session>, paymentInstance: NarrowedEntity< Payment, diff --git a/modules/payments/server/customer.ts b/modules/payments/server/customer.ts index bea4cc7a0a..6a27e5e531 100644 --- a/modules/payments/server/customer.ts +++ b/modules/payments/server/customer.ts @@ -1,10 +1,11 @@ import type Stripe from 'stripe'; -import { stripe } from './stripe-client.js'; +import { getStripeClient } from './stripe-client.js'; /** * simple wrapper function to stripes create customer call. * See the JSDoc of the option parameter or https://stripe.com/docs/api/customers/create for more details. */ -export const createStripeCustomer = async (customerOptions: Stripe.CustomerCreateParams) => { - return stripe.customers.create(customerOptions); +export const createStripeCustomer = async (customerOptions: Stripe.CustomerCreateParams, scope?: string) => { + const stripeClient = getStripeClient(scope); + return stripeClient.customers.create(customerOptions); }; diff --git a/modules/payments/server/payment.ts b/modules/payments/server/payment.ts index 1ac498cdf7..9582a32caa 100644 --- a/modules/payments/server/payment.ts +++ b/modules/payments/server/payment.ts @@ -5,20 +5,22 @@ import { DateTime } from 'luxon'; import type Stripe from 'stripe'; import type { ChosenProduct } from '../db/chosenProduct.js'; import { PaymentError } from '../db/paymentError.js'; -import { Payment } from '../db/payments.js'; +import type { Payment } from '../db/payments.js'; import { UserReference } from '../db/userReference.js'; import { isStripeRefundReason, refundStripePayment } from './refund.js'; -import { stripe } from './stripe-client.js'; +import { getStripeClient } from './stripe-client.js'; export interface CreatePaymentOptions { + scope: string; paymentInstance: Payment; products: ChosenProduct[]; userID?: string; } -export const createPayment = async ({ paymentInstance, products, userID }: CreatePaymentOptions) => { +export const createPayment = async ({ scope, paymentInstance, products, userID }: CreatePaymentOptions) => { paymentInstance._id = A.getGloballyUniqueID(); try { + paymentInstance.scope = scope; paymentInstance.status = 'created'; paymentInstance.products = products; // if userID is not given here we assume a guest user and fill in email and name when capturing the order @@ -35,6 +37,7 @@ export const createPayment = async ({ paymentInstance, products, userID }: Creat }; export const cancelPayment = async ({ paymentInstance, reason }: { paymentInstance: Payment; reason?: string }) => { + const stripe = getStripeClient(paymentInstance.scope); if (paymentInstance.status !== 'issued') throw new Error(`Cannot cancel payment with status "${paymentInstance.status}"`); if (!paymentInstance.processorInfo.stripeSessionID) throw new Error('stripe payment without stripeSessionID?'); @@ -53,13 +56,14 @@ export const refundPayment = async ({ reason?: Stripe.RefundCreateParams.Reason | string; refund?: number; }) => { + const stripe = getStripeClient(paymentInstance.scope); if (paymentInstance.status !== 'paid') throw new Error(`Cannot refund payment with status "${paymentInstance.status}"`); if (!isStripeRefundReason(reason)) throw new Error( `Stripe only accepts "duplicate" | "fraudulent" | "requested_by_customer" as a refund reason, got ${reason}`, ); - const response = await refundStripePayment({ paymentInstance, reason, refund }); + const response = await refundStripePayment({ stripe, paymentInstance, reason, refund }); paymentInstance.processorInfo.stripeRefundID = response.id; paymentInstance.status = 'refund'; await paymentInstance.save(); @@ -77,7 +81,6 @@ export const migratePaymentsRedundantIDs = async (klass: typeof Payment) => { if (!productPricing.product.reference) continue; productPricing.product._id = productPricing.product.reference + '_copied_' + A.getGloballyUniqueID(); } - console.log('.'); bulkWrite.find({ _id: issue._id }).update({ $set: { products: issue.products } }); } await bulkWrite.execute({}); diff --git a/modules/payments/server/products.ts b/modules/payments/server/products.ts index 0a4a72e4d6..0616c41e0d 100644 --- a/modules/payments/server/products.ts +++ b/modules/payments/server/products.ts @@ -3,12 +3,11 @@ import { getRawCollection } from '@adornis/baseql/server/collections.js'; import type Stripe from 'stripe'; import type { BasicProduct } from '../db/basic-product.js'; import type { Price } from '../db/prices.js'; -import { stripe } from './stripe-client.js'; const productDiffers = (stripeProduct: Stripe.Response<Stripe.Product>, product: BasicProduct) => stripeProduct.name !== product.name || (!!product.description && stripeProduct.description !== product.description); -export const ensureStripeProduct = async (product: BasicProduct) => { +export const ensureStripeProduct = async (stripe: Stripe, product: BasicProduct) => { if (!product.reference) throw new Error('Cannot ensure stripe product without reference'); try { const stripeProduct = await stripe.products.retrieve(product.reference); @@ -17,20 +16,20 @@ export const ensureStripeProduct = async (product: BasicProduct) => { if (product.description) productUpdate.description = product.description; await stripe.products.update(product.reference, productUpdate); } - await ensurePrices(product); + await ensurePrices(stripe, product); } catch (err: any) { if (err.statusCode === 404) { const productCreateData: Stripe.ProductCreateParams = { name: product.name, id: product.reference }; if (product.description) productCreateData.description = product.description; await stripe.products.create(productCreateData); - await ensurePrices(product); + await ensurePrices(stripe, product); return; } throw err; } }; -const createPrice = async (productID: string, price: Price) => { +const createPrice = async (stripe: Stripe, productID: string, price: Price) => { const priceCreateData: Stripe.PriceCreateParams = { currency: price.currency, product: productID, @@ -41,9 +40,9 @@ const createPrice = async (productID: string, price: Price) => { return stripe.prices.create(priceCreateData); }; -const ensurePrice = async (productID: string, price: Price) => { +const ensurePrice = async (stripe: Stripe, productID: string, price: Price) => { if (!price.stripeID) { - const stripePrice = await createPrice(productID, price); + const stripePrice = await createPrice(stripe, productID, price); price.stripeID = stripePrice.id; return true; } @@ -51,14 +50,14 @@ const ensurePrice = async (productID: string, price: Price) => { const stripePrice = await stripe.prices.retrieve(price.stripeID); if (priceDiffers(stripePrice, price)) { await stripe.prices.update(price.stripeID, { active: false }); - const newStripePrice = await createPrice(productID, price); + const newStripePrice = await createPrice(stripe, productID, price); price.stripeID = newStripePrice.id; return true; } return false; } catch (err: any) { if (err.statusCode === 404) { - const stripePrice = await createPrice(productID, price); + const stripePrice = await createPrice(stripe, productID, price); price.stripeID = stripePrice.id; return true; } @@ -66,10 +65,10 @@ const ensurePrice = async (productID: string, price: Price) => { } }; -const ensurePrices = async (product: BasicProduct) => { +const ensurePrices = async (stripe: Stripe, product: BasicProduct) => { let modified = false; for (const price of product.prices) { - const check = await ensurePrice(product.reference!, price); + const check = await ensurePrice(stripe, product.reference!, price); modified ||= check; } if (modified) { diff --git a/modules/payments/server/refund.ts b/modules/payments/server/refund.ts index dbeb7a6708..90fa0d1abc 100644 --- a/modules/payments/server/refund.ts +++ b/modules/payments/server/refund.ts @@ -1,16 +1,17 @@ import type Stripe from 'stripe'; import { type Payment } from '../db/payments.js'; -import { stripe } from './stripe-client.js'; export const isStripeRefundReason = (reason?: string): reason is Stripe.RefundCreateParams.Reason => { return !reason || ['duplicate', 'fraudulent', 'requested_by_customer'].includes(reason); }; export const refundStripePayment = async ({ + stripe, paymentInstance, reason, refund, }: { + stripe: Stripe; paymentInstance: Payment; reason?: Stripe.RefundCreateParams.Reason; refund?: number; diff --git a/modules/payments/server/router-helper.ts b/modules/payments/server/router-helper.ts index 54c3012481..463b919090 100644 --- a/modules/payments/server/router-helper.ts +++ b/modules/payments/server/router-helper.ts @@ -1,5 +1,5 @@ import { baseConfig } from '@adornis/config/baseConfig.js'; -import { type Response } from 'express'; +import type { Response } from 'express'; export const badRequest = (response: Response, reason: string) => { response.status(400); diff --git a/modules/payments/server/router.ts b/modules/payments/server/router.ts index bb4479995c..47e03633a9 100644 --- a/modules/payments/server/router.ts +++ b/modules/payments/server/router.ts @@ -1,139 +1,13 @@ -import { A } from '@adornis/base/env-info.js'; -import { logger } from '@adornis/base/logging.js'; -import { getTaskByID } from '@adornis/tasks/server/tasks.js'; -import express, { Router, type Request } from 'express'; -import { URL } from 'url'; -import { Invoice } from '../db/invoice.js'; -import { type Payment } from '../db/payments.js'; -import { ProcessorInfo } from '../db/processorInfo.js'; -import { UserReference } from '../db/userReference.js'; import { paymentConfig } from './config.js'; -import { migrate } from './migrations.js'; -import { cancelPayment } from './payment.js'; -import { badRequest, notFound } from './router-helper.js'; -import { stripe } from './stripe-client.js'; -import { handleStripeWebhookEvents } from './webhookHandler.js'; -import { successfullyCompleteSession } from './checkout.js'; -import { getByID } from '@adornis/baseql/operations/mongo.js'; -import { lastValueFrom } from 'rxjs'; - -export const STRIPE_CHECKOUT_REDIRECT_PATH = '/api/payments/stripe/checkout'; -export const STRIPE_CHECKOUT_WEBHOOK_PATH = '/api/payments/stripe/webhooks'; - -const verifyStripeWebhookRequest = (request: Request) => { - const signature = request.headers['stripe-signature']; - if (!signature) { - logger.warn('stripe webhook endpoint got request without stripe-signature header'); - return; - } - try { - return stripe.webhooks.constructEvent(request.body, signature, paymentConfig.get('stripeWebhookSecret')); - } catch (err: any) { - logger.warn({ err }, `Stripe Webhook signature verification failed.`); - return; - } -}; - -export const paymentsRouter = ({ - completionPendingRedirect, - PaymentClass, - SubscriptionClass, - InvoiceClass = Invoice, -}: { - completionPendingRedirect?: string; - PaymentClass: typeof Payment; - SubscriptionClass?: typeof Payment; - InvoiceClass?: typeof Invoice; -}) => { - migrate(PaymentClass).catch(err => logger.error({ err }, 'Migration of payments failed')); - const getPaymentClass = (mode: string) => - mode === 'subscription' ? SubscriptionClass ?? PaymentClass : PaymentClass; - - const router = Router(); - router.use(STRIPE_CHECKOUT_REDIRECT_PATH, async (request, response) => { - const redirectStatus = request.query.redirect_status as string; - const paymentID = request.query.paymentsID as string; - const stripeSessionID = request.query.token as string; - const mode = request.query.mode as string; - const redirect = request.query.redirect as string; // could use some checking on where we redirect here, like allowedDomains - if (!redirectStatus) return badRequest(response, 'queryParam redirect_status not set.'); - if (!paymentID) return badRequest(response, 'queryParam paymentsID not set.'); - if (!stripeSessionID) return badRequest(response, 'queryParam token not set. This is normally set by stripe.'); - if (!mode) return badRequest(response, 'queryParam mode not set.'); - if (!redirect) return badRequest(response, 'queryParam redirect not set.'); - - const redirectUrl = new URL(A.absoluteUrl(redirect)); - redirectUrl.searchParams.append('token', stripeSessionID); - redirectUrl.searchParams.append('id', paymentID); - if (redirectStatus === 'canceled') { - logger.info({ orderID: paymentID }, 'Payments: cancelling order'); - try { - const paymentInstance: any = await getByID( - getPaymentClass(mode), - paymentID, - )({ - _id: 1, - _class: 1, - status: 1, - paymentType: 1, - userReference: UserReference.allFields, - processorInfo: ProcessorInfo.allFields, - }); - if (!paymentInstance) { - return notFound(response, 'Payment not found. Is the paymentsID query param correctly set?'); - } - await cancelPayment({ paymentInstance, reason: 'User cancelled payment' }); - } catch (error: any) { - logger.error({ err: error }, 'error while cancelling stripe payment after user cancelled it'); - } - response.redirect(redirectUrl.toString()); - return; - } - logger.info({ orderID: paymentID }, 'Payments: successful stripe checkout session'); - - const completionTaskID = await successfullyCompleteSession({ - stripeSessionID, - PaymentClass: getPaymentClass(mode), - paymentID, - }); - redirectUrl.searchParams.append('task', completionTaskID); - if (completionPendingRedirect) { - const pendingUrl = new URL(A.absoluteUrl(completionPendingRedirect)); - pendingUrl.searchParams.append('token', stripeSessionID); - pendingUrl.searchParams.append('id', paymentID); - pendingUrl.searchParams.append('task', completionTaskID); - response.redirect(pendingUrl.toString()); - return; - } - try { - const task = getTaskByID(completionTaskID); - if (!task) { - response.status(500).end('Error while creating payment completion task'); - return; - } - await lastValueFrom(task?.observable); - redirectUrl.searchParams.append('captured', 'true'); - response.redirect(redirectUrl.toString()); - } catch (error: any) { - if (error.reason === 'NOT_FOUND') - return notFound(response, 'Payment not found. Is the paymentsID query param correctly set?'); - logger.warn( - { paymentID, stripeSessionID, err: error }, - 'Error while updating payment with stripe customer details', - ); - redirectUrl.searchParams.append('captured', 'false'); - response.redirect(redirectUrl.toString()); - } - }); - router.use(STRIPE_CHECKOUT_WEBHOOK_PATH, express.raw({ type: 'application/json' }), async (request, response) => { - const event = verifyStripeWebhookRequest(request); - if (!event) return response.sendStatus(400); - try { - await handleStripeWebhookEvents(event, response, getPaymentClass, InvoiceClass); - } catch (err: any) { - logger.error({ err, event }, 'Error while handling stripe webhook event'); - return response.sendStatus(500); - } +import { stripeRouter } from './stripeRouter.js'; +import type { StripeRouterOptions } from './types.js'; + +export const paymentsRouter = ( + options: Omit<StripeRouterOptions, 'stripePrivateKey' | 'stripePublishableKey' | 'stripeWebhookSecret'>, +) => + stripeRouter({ + ...options, + stripePrivateKey: paymentConfig.get('stripePrivateKey'), + stripePublishableKey: paymentConfig.get('stripePublishableKey'), + stripeWebhookSecret: paymentConfig.get('stripeWebhookSecret'), }); - return router; -}; diff --git a/modules/payments/server/stripe-client.ts b/modules/payments/server/stripe-client.ts index aee614996e..027522810c 100644 --- a/modules/payments/server/stripe-client.ts +++ b/modules/payments/server/stripe-client.ts @@ -1,4 +1,16 @@ +import type { Maybe } from '@adornis/base/utilTypes.js'; import Stripe from 'stripe'; -import { paymentConfig } from './config.js'; -export const stripe = new Stripe(paymentConfig.get('stripePrivateKey'), { apiVersion: '2023-08-16' }); +const stripeClients: Record<string, Stripe> = {}; + +export const getStripeClient = (scope?: Maybe<string>) => { + const realScope = scope ?? 'default'; + if (!stripeClients[realScope]) throw new Error(`No stripe client found for scope ${realScope}`); + return stripeClients[realScope]; +}; + +export const createStripeClient = (scope: string, apiKey: string) => { + if (stripeClients[scope]) throw new Error(`Stripe client already exists for scope ${scope}`); + stripeClients[scope] = new Stripe(apiKey, { apiVersion: '2023-08-16' }); + return stripeClients[scope]; +}; diff --git a/modules/payments/server/stripeRouter.ts b/modules/payments/server/stripeRouter.ts new file mode 100644 index 0000000000..eccd8b7eca --- /dev/null +++ b/modules/payments/server/stripeRouter.ts @@ -0,0 +1,149 @@ +import { A } from '@adornis/base/env-info.js'; +import { logger } from '@adornis/base/logging.js'; +import { getByID } from '@adornis/baseql/operations/mongo.js'; +import { getTaskByID } from '@adornis/tasks/server/tasks.js'; +import express, { Router, type Request } from 'express'; +import { lastValueFrom } from 'rxjs'; +import { URL } from 'url'; +import { Invoice } from '../db/invoice.js'; +import { ProcessorInfo } from '../db/processorInfo.js'; +import { UserReference } from '../db/userReference.js'; +import { successfullyCompleteSession } from './checkout.js'; +import { migrate } from './migrations.js'; +import { cancelPayment } from './payment.js'; +import { badRequest, notFound } from './router-helper.js'; +import { createStripeClient, getStripeClient } from './stripe-client.js'; +import type { StripeRouterOptions } from './types.js'; +import { handleStripeWebhookEvents } from './webhookHandler.js'; + +export const STRIPE_CHECKOUT_REDIRECT_PATH = (scope = 'default') => + `/api/payments/${scope !== 'default' ? `${scope}/` : ''}stripe/checkout`; +export const STRIPE_CHECKOUT_WEBHOOK_PATH = (scope = 'default') => + `/api/payments/${scope !== 'default' ? `${scope}/` : ''}stripe/webhooks`; + +const verifyStripeWebhookRequest = (request: Request, scope: string, stripeWebhookSecret: string) => { + const signature = request.headers['stripe-signature']; + if (!signature) { + logger.warn('stripe webhook endpoint got request without stripe-signature header'); + return; + } + try { + return getStripeClient(scope).webhooks.constructEvent(request.body, signature, stripeWebhookSecret); + } catch (err: any) { + logger.warn({ err }, `Stripe Webhook signature verification failed.`); + } +}; + +export const stripeRouter = ({ + completionPendingRedirect, + scope = 'default', + stripePrivateKey, + stripePublishableKey, + stripeWebhookSecret, + PaymentClass, + SubscriptionClass, + InvoiceClass = Invoice, +}: StripeRouterOptions) => { + if (scope === 'default') migrate(PaymentClass).catch(err => logger.error({ err }, 'Migration of payments failed')); + const stripe = createStripeClient(scope, stripePrivateKey); + + const getPaymentClass = (mode: string) => + mode === 'subscription' ? SubscriptionClass ?? PaymentClass : PaymentClass; + + const router = Router(); + router.use(STRIPE_CHECKOUT_REDIRECT_PATH(scope), async (request, response) => { + const redirectStatus = request.query.redirect_status as string; + const paymentID = request.query.paymentsID as string; + const stripeSessionID = request.query.token as string; + const mode = request.query.mode as string; + const redirect = request.query.redirect as string; // could use some checking on where we redirect here, like allowedDomains + if (!redirectStatus) return badRequest(response, 'queryParam redirect_status not set.'); + if (!paymentID) return badRequest(response, 'queryParam paymentsID not set.'); + if (!stripeSessionID) return badRequest(response, 'queryParam token not set. This is normally set by stripe.'); + if (!mode) return badRequest(response, 'queryParam mode not set.'); + if (!redirect) return badRequest(response, 'queryParam redirect not set.'); + + const redirectUrl = new URL(A.absoluteUrl(redirect)); + redirectUrl.searchParams.append('token', stripeSessionID); + redirectUrl.searchParams.append('id', paymentID); + if (redirectStatus === 'canceled') { + logger.info({ orderID: paymentID }, 'Payments: cancelling order'); + try { + const paymentInstance: any = await getByID( + getPaymentClass(mode), + paymentID, + )({ + _id: 1, + _class: 1, + status: 1, + scope: 1, + paymentType: 1, + userReference: UserReference.allFields, + processorInfo: ProcessorInfo.allFields, + }); + if (!paymentInstance) { + return notFound(response, 'Payment not found. Is the paymentsID query param correctly set?'); + } + if ((paymentInstance.scope ?? 'default') !== scope) { + return notFound(response, 'Payment not found. Is the paymentsID query param correctly set? (scope mismatch)'); + } + await cancelPayment({ stripe, paymentInstance, reason: 'User cancelled payment' }); + } catch (error: any) { + logger.error({ err: error }, 'error while cancelling stripe payment after user cancelled it'); + } + response.redirect(redirectUrl.toString()); + return; + } + logger.info({ orderID: paymentID }, 'Payments: successful stripe checkout session'); + + const completionTaskID = await successfullyCompleteSession({ + stripe, + stripeSessionID, + PaymentClass: getPaymentClass(mode), + paymentID, + }); + redirectUrl.searchParams.append('task', completionTaskID); + if (completionPendingRedirect) { + const pendingUrl = new URL(A.absoluteUrl(completionPendingRedirect)); + pendingUrl.searchParams.append('token', stripeSessionID); + pendingUrl.searchParams.append('id', paymentID); + pendingUrl.searchParams.append('task', completionTaskID); + response.redirect(pendingUrl.toString()); + return; + } + try { + const task = getTaskByID(completionTaskID); + if (!task) { + response.status(500).end('Error while creating payment completion task'); + return; + } + await lastValueFrom(task.observable); + redirectUrl.searchParams.append('captured', 'true'); + response.redirect(redirectUrl.toString()); + } catch (error: any) { + if (error.reason === 'NOT_FOUND') + return notFound(response, 'Payment not found. Is the paymentsID query param correctly set?'); + logger.warn( + { paymentID, stripeSessionID, err: error }, + 'Error while updating payment with stripe customer details', + ); + redirectUrl.searchParams.append('captured', 'false'); + response.redirect(redirectUrl.toString()); + } + }); + router.use( + STRIPE_CHECKOUT_WEBHOOK_PATH(scope), + express.raw({ type: 'application/json' }), + async (request, response) => { + const event = verifyStripeWebhookRequest(request, scope, stripeWebhookSecret); + if (!event) return response.sendStatus(400); + try { + await handleStripeWebhookEvents({ stripe, event, response, getPaymentClass, InvoiceClass }); + } catch (err: any) { + logger.error({ err, event }, 'Error while handling stripe webhook event'); + return response.sendStatus(500); + } + }, + ); + return router; +}; diff --git a/modules/payments/server/types.ts b/modules/payments/server/types.ts new file mode 100644 index 0000000000..d6b56a9d65 --- /dev/null +++ b/modules/payments/server/types.ts @@ -0,0 +1,13 @@ +import type { Invoice } from '../db/invoice.js'; +import type { Payment } from '../db/payments.js'; + +export interface StripeRouterOptions { + completionPendingRedirect?: string; + scope?: string; + stripePrivateKey: string; + stripePublishableKey: string; + stripeWebhookSecret: string; + PaymentClass: typeof Payment; + SubscriptionClass?: typeof Payment; + InvoiceClass?: typeof Invoice; +} diff --git a/modules/payments/server/webhookHandler.ts b/modules/payments/server/webhookHandler.ts index ac1de63e8b..a06e749bcd 100644 --- a/modules/payments/server/webhookHandler.ts +++ b/modules/payments/server/webhookHandler.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/only-throw-error */ import { logger } from '@adornis/base/logging.js'; import { constructValue } from '@adornis/baseql/entities/construct.js'; import type { EntityData } from '@adornis/baseql/entities/types.js'; @@ -7,15 +9,15 @@ import type Stripe from 'stripe'; import { Invoice } from '../db/invoice.js'; import { PaymentError } from '../db/paymentError.js'; import type { Payment } from '../db/payments.js'; -import { stripe } from './stripe-client.js'; import { successfullyCompleteSession } from './checkout.js'; -type StripeEventHandler = ( - event: Stripe.Event, - response: Response, - getPaymentClass: (mode: string) => typeof Payment, - InvoiceClass: typeof Invoice, -) => Promise<void>; +type StripeEventHandler = (options: { + event: Stripe.Event; + response: Response; + stripe: Stripe; + getPaymentClass: (mode: string) => typeof Payment; + InvoiceClass: typeof Invoice; +}) => Promise<void>; const safeSave = async (payment: Payment) => { try { @@ -27,6 +29,7 @@ const safeSave = async (payment: Payment) => { }; const getPaymentInstanceFromInvoice = async ( + stripe: Stripe, getPaymentClass: (mode: string) => typeof Payment, invoice: Stripe.Invoice, ) => { @@ -44,23 +47,27 @@ const getPaymentInstanceFromInvoice = async ( return { collection, rawPaymentInstance, PaymentClass }; }; -const handleInvoicePaymentFailed: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleInvoicePaymentFailed: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const invoice = event.data.object as Stripe.Invoice; - console.log('invloice payment failed', invoice.id, invoice.status); + logger.warn({ invoice }, 'invoice payment failed'); response.sendStatus(204); }; -const handleInvoicePaymentActionRequired: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleInvoicePaymentActionRequired: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { // const invoice = event.data.object as Stripe.Invoice; response.sendStatus(204); }; -const handleInvoicePaid: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleInvoicePaid: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const invoice = event.data.object as Stripe.Invoice; - console.log('invoice payment paid', invoice.id, invoice.subscription); + logger.info({ invoice }, 'invoice payment paid'); try { - const { rawPaymentInstance, PaymentClass } = await getPaymentInstanceFromInvoice(getPaymentClass, invoice).catch( + const { rawPaymentInstance, PaymentClass } = await getPaymentInstanceFromInvoice( + stripe, + getPaymentClass, + invoice, + ).catch( // ? this is ugly but works, can i do error handling in a more readable way? err => { throw { err, step: 'get' }; @@ -107,20 +114,19 @@ const handleInvoicePaid: StripeEventHandler = async (event, response, getPayment response.sendStatus(204); }; -const handleInvoiceFinalizationFailed: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleInvoiceFinalizationFailed: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { // const invoice = event.data.object as Stripe.Invoice; response.sendStatus(204); }; -const handleInvoiceCreated: StripeEventHandler = async (event, response, getPaymentClass, InvoiceClass) => { +const handleInvoiceCreated: StripeEventHandler = async ({ event, response, getPaymentClass, InvoiceClass, stripe }) => { const invoice = event.data.object as Stripe.Invoice; try { const localInvoice = await InvoiceClass.createFromStripeInvoice(invoice); - const { collection, rawPaymentInstance } = await getPaymentInstanceFromInvoice(getPaymentClass, invoice); + const { collection, rawPaymentInstance } = await getPaymentInstanceFromInvoice(stripe, getPaymentClass, invoice); const updateResult = await collection.updateOne( { _id: rawPaymentInstance._id }, { - // @ts-expect-error strange typing in pnpm run lint(-rough) $push: { invoices: localInvoice.toJSON() }, }, ); @@ -133,7 +139,7 @@ const handleInvoiceCreated: StripeEventHandler = async (event, response, getPaym } }; -const handleSubsctiptionUpdated: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleSubsctiptionUpdated: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const subscription = event.data.object as Stripe.Subscription; if (event.data.previous_attributes && 'status' in event.data.previous_attributes) { const collection = await getRawCollection<EntityData<Payment>>(getPaymentClass('subscription')._collectionName); @@ -145,14 +151,14 @@ const handleSubsctiptionUpdated: StripeEventHandler = async (event, response, ge response.sendStatus(204); }; -const handleSubsctiptionDeleted: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleSubsctiptionDeleted: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const subscription = event.data.object as Stripe.Subscription; const collection = await getRawCollection<EntityData<Payment>>(getPaymentClass('subscription')._collectionName); await collection.updateOne({ _id: subscription.metadata.paymentReference }, { $set: { status: 'canceled' } }); response.sendStatus(204); }; -const handleSessionExpired: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleSessionExpired: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const session = event.data.object as Stripe.Checkout.Session; const paymentID = session.client_reference_id; if (!paymentID) { @@ -173,7 +179,7 @@ const handleSessionExpired: StripeEventHandler = async (event, response, getPaym // construct value here so all hooks are executed when saving the instance const payment: Payment = constructValue(rawPaymentInstance); // if we expired the session because the user clicked "abort and return to store" we dont want to overwrite that. - if (payment && payment.status !== 'canceled') { + if (payment.status !== 'canceled') { payment.status = 'expired'; const err = await safeSave(payment); if (err) { @@ -184,7 +190,7 @@ const handleSessionExpired: StripeEventHandler = async (event, response, getPaym response.sendStatus(204); }; -const handleSessionCompleted: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleSessionCompleted: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const session = event.data.object as Stripe.Checkout.Session; try { if (!session.client_reference_id) { @@ -192,6 +198,7 @@ const handleSessionCompleted: StripeEventHandler = async (event, response, getPa return; } await successfullyCompleteSession({ + stripe, stripeSessionID: session.id, PaymentClass: getPaymentClass(session.mode), paymentID: session.client_reference_id, @@ -206,7 +213,7 @@ const handleSessionCompleted: StripeEventHandler = async (event, response, getPa } }; -const handleChargeFailed: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleChargeFailed: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { const charge = event.data.object as Stripe.Charge; const sessionList = await stripe.checkout.sessions.list({ payment_intent: charge.payment_intent as string }); const session = sessionList.data[0]; @@ -224,7 +231,9 @@ const handleChargeFailed: StripeEventHandler = async (event, response, getPaymen response.sendStatus(404); return; } - const collection = await getRawCollection(getPaymentClass(session.mode)._collectionName); + const collection = await getRawCollection<{ _id: string; errors?: any[] }>( + getPaymentClass(session.mode)._collectionName, + ); const payment = await collection.findOne({ 'processorInfo.stripeSessionID': session.id }); if (!payment) { logger.warn( @@ -242,7 +251,6 @@ const handleChargeFailed: StripeEventHandler = async (event, response, getPaymen }).toJSON(); const update = payment.errors ? { $push: { errors: error } } : { $set: { errors: [error] } }; try { - // @ts-expect-error await collection.updateOne({ _id: payment._id }, update); response.sendStatus(204); } catch (err: any) { @@ -251,41 +259,42 @@ const handleChargeFailed: StripeEventHandler = async (event, response, getPaymen } }; -const handleChargeSucceeded: StripeEventHandler = async (event, response, getPaymentClass) => { +const handleChargeSucceeded: StripeEventHandler = async ({ event, response, getPaymentClass, stripe }) => { // TODO do we need to listen for this? // const charge = event.data.object as Stripe.Charge; response.sendStatus(204); }; -export const handleStripeWebhookEvents: StripeEventHandler = async ( +export const handleStripeWebhookEvents: StripeEventHandler = async ({ + stripe, event, response, getPaymentClass, - InvoiceClass: typeof Invoice = Invoice, -) => { + InvoiceClass = Invoice, +}) => { switch (event.type) { case 'invoice.payment_failed': - return handleInvoicePaymentFailed(event, response, getPaymentClass, InvoiceClass); + return handleInvoicePaymentFailed({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'invoice.payment_action_required': - return handleInvoicePaymentActionRequired(event, response, getPaymentClass, InvoiceClass); + return handleInvoicePaymentActionRequired({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'invoice.paid': - return handleInvoicePaid(event, response, getPaymentClass, InvoiceClass); + return handleInvoicePaid({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'invoice.finalization_failed': - return handleInvoiceFinalizationFailed(event, response, getPaymentClass, InvoiceClass); + return handleInvoiceFinalizationFailed({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'invoice.created': - return handleInvoiceCreated(event, response, getPaymentClass, InvoiceClass); + return handleInvoiceCreated({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'customer.subscription.updated': - return handleSubsctiptionUpdated(event, response, getPaymentClass, InvoiceClass); + return handleSubsctiptionUpdated({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'customer.subscription.deleted': - return handleSubsctiptionDeleted(event, response, getPaymentClass, InvoiceClass); + return handleSubsctiptionDeleted({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'checkout.session.expired': - return handleSessionExpired(event, response, getPaymentClass, InvoiceClass); + return handleSessionExpired({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'checkout.session.completed': - return handleSessionCompleted(event, response, getPaymentClass, InvoiceClass); + return handleSessionCompleted({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'charge.failed': - return handleChargeFailed(event, response, getPaymentClass, InvoiceClass); + return handleChargeFailed({ event, response, getPaymentClass, InvoiceClass, stripe }); case 'charge.succeeded': - return handleChargeSucceeded(event, response, getPaymentClass, InvoiceClass); + return handleChargeSucceeded({ event, response, getPaymentClass, InvoiceClass, stripe }); default: logger.info({ event }, 'Unhandled Stripe Webhook Event!'); response.sendStatus(404); -- GitLab From 4f0fbd31b30ef0981b582e485dc6333c1e4bd073 Mon Sep 17 00:00:00 2001 From: theld <lars@adornis.de> Date: Tue, 29 Apr 2025 13:42:09 +0000 Subject: [PATCH 2/3] chore: fix missed param --- modules/payments/server/stripeRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/payments/server/stripeRouter.ts b/modules/payments/server/stripeRouter.ts index eccd8b7eca..0e00155d80 100644 --- a/modules/payments/server/stripeRouter.ts +++ b/modules/payments/server/stripeRouter.ts @@ -87,7 +87,7 @@ export const stripeRouter = ({ if ((paymentInstance.scope ?? 'default') !== scope) { return notFound(response, 'Payment not found. Is the paymentsID query param correctly set? (scope mismatch)'); } - await cancelPayment({ stripe, paymentInstance, reason: 'User cancelled payment' }); + await cancelPayment({ paymentInstance, reason: 'User cancelled payment' }); } catch (error: any) { logger.error({ err: error }, 'error while cancelling stripe payment after user cancelled it'); } -- GitLab From 63bffbd5d8ad1ea0c0ea5d540560537aa2a12c12 Mon Sep 17 00:00:00 2001 From: theld <lars@adornis.de> Date: Tue, 29 Apr 2025 13:44:01 +0000 Subject: [PATCH 3/3] chore: fix more broken typing --- modules/payments/server/webhookHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/payments/server/webhookHandler.ts b/modules/payments/server/webhookHandler.ts index a06e749bcd..b39e5b2324 100644 --- a/modules/payments/server/webhookHandler.ts +++ b/modules/payments/server/webhookHandler.ts @@ -127,7 +127,7 @@ const handleInvoiceCreated: StripeEventHandler = async ({ event, response, getPa const updateResult = await collection.updateOne( { _id: rawPaymentInstance._id }, { - $push: { invoices: localInvoice.toJSON() }, + $push: { invoices: localInvoice.toJSON() as any }, }, ); if (!updateResult.modifiedCount) -- GitLab