From de9e7f97a4649be062a7c5d7245b6b128370a5c0 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 30 Oct 2023 00:54:09 -0500 Subject: [PATCH] feat: add idempotent request support for payment apis (#4753) --- .../server/src/modules/payment/resolver.ts | 27 +++-- .../server/src/modules/payment/schedule.ts | 108 +++++++++++------- .../server/src/modules/payment/service.ts | 100 +++++++++------- packages/backend/server/src/schema.gql | 8 +- .../general-setting/billing/index.tsx | 37 ++++-- .../general-setting/plans/plan-card.tsx | 52 ++++++--- .../src/graphql/cancel-subscription.gql | 4 +- .../src/graphql/create-checkout-link.gql | 7 +- .../frontend/graphql/src/graphql/index.ts | 19 +-- .../src/graphql/resume-subscription.gql | 4 +- .../graphql/update-subscription-billing.gql | 10 +- packages/frontend/graphql/src/schema.ts | 6 +- 12 files changed, 244 insertions(+), 138 deletions(-) diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index 22d24c43a..f9950bfd9 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -189,12 +189,14 @@ export class SubscriptionResolver { async checkout( @CurrentUser() user: User, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) - recurring: SubscriptionRecurring + recurring: SubscriptionRecurring, + @Args('idempotencyKey') idempotencyKey: string ) { const session = await this.service.createCheckoutSession({ user, recurring, redirectUrl: `${this.config.baseUrl}/upgrade-success`, + idempotencyKey, }); if (!session.url) { @@ -217,22 +219,33 @@ export class SubscriptionResolver { } @Mutation(() => UserSubscriptionType) - async cancelSubscription(@CurrentUser() user: User) { - return this.service.cancelSubscription(user.id); + async cancelSubscription( + @CurrentUser() user: User, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.cancelSubscription(idempotencyKey, user.id); } @Mutation(() => UserSubscriptionType) - async resumeSubscription(@CurrentUser() user: User) { - return this.service.resumeCanceledSubscription(user.id); + async resumeSubscription( + @CurrentUser() user: User, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.resumeCanceledSubscription(idempotencyKey, user.id); } @Mutation(() => UserSubscriptionType) async updateSubscriptionRecurring( @CurrentUser() user: User, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) - recurring: SubscriptionRecurring + recurring: SubscriptionRecurring, + @Args('idempotencyKey') idempotencyKey: string ) { - return this.service.updateSubscriptionRecurring(user.id, recurring); + return this.service.updateSubscriptionRecurring( + idempotencyKey, + user.id, + recurring + ); } } diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts index 423bdf691..e27838e20 100644 --- a/packages/backend/server/src/modules/payment/schedule.ts +++ b/packages/backend/server/src/modules/payment/schedule.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; @Injectable() export class ScheduleManager { private _schedule: Stripe.SubscriptionSchedule | null = null; + private readonly logger = new Logger(ScheduleManager.name); constructor(private readonly stripe: Stripe) {} @@ -50,7 +51,10 @@ export class ScheduleManager { if (typeof schedule === 'string') { const s = await this.stripe.subscriptionSchedules .retrieve(schedule) - .catch(() => undefined); + .catch(e => { + this.logger.error('Failed to retrieve subscription schedule', e); + return undefined; + }); return ScheduleManager.create(this.stripe, s); } else { @@ -58,7 +62,10 @@ export class ScheduleManager { } } - async fromSubscription(subscription: string | Stripe.Subscription) { + async fromSubscription( + idempotencyKey: string, + subscription: string | Stripe.Subscription + ) { if (typeof subscription === 'string') { subscription = await this.stripe.subscriptions.retrieve(subscription, { expand: ['schedule'], @@ -68,9 +75,10 @@ export class ScheduleManager { if (subscription.schedule) { return await this.fromSchedule(subscription.schedule); } else { - const schedule = await this.stripe.subscriptionSchedules.create({ - from_subscription: subscription.id, - }); + const schedule = await this.stripe.subscriptionSchedules.create( + { from_subscription: subscription.id }, + { idempotencyKey } + ); return await this.fromSchedule(schedule); } @@ -80,7 +88,7 @@ export class ScheduleManager { * Cancel a subscription by marking schedule's end behavior to `cancel`. * At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription. */ - async cancel() { + async cancel(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -111,13 +119,17 @@ export class ScheduleManager { }; } - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: [phases], - end_behavior: 'cancel', - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: [phases], + end_behavior: 'cancel', + }, + { idempotencyKey } + ); } - async resume() { + async resume(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -156,21 +168,27 @@ export class ScheduleManager { }); } - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: phases, - end_behavior: 'release', - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: phases, + end_behavior: 'release', + }, + { idempotencyKey } + ); } - async release() { + async release(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } - await this.stripe.subscriptionSchedules.release(this._schedule.id); + await this.stripe.subscriptionSchedules.release(this._schedule.id, { + idempotencyKey, + }); } - async update(price: string, coupon?: string) { + async update(idempotencyKey: string, price: string, coupon?: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -184,31 +202,37 @@ export class ScheduleManager { this.currentPhase.items[0].price === price && (!coupon || this.currentPhase.coupon === coupon) ) { - await this.stripe.subscriptionSchedules.release(this._schedule.id); + await this.stripe.subscriptionSchedules.release(this._schedule.id, { + idempotencyKey, + }); this._schedule = null; } else { - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: [ - { - items: [ - { - price: this.currentPhase.items[0].price as string, - }, - ], - start_date: this.currentPhase.start_date, - end_date: this.currentPhase.end_date, - }, - { - items: [ - { - price: price, - quantity: 1, - }, - ], - coupon, - }, - ], - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + }, + ], + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }, + { + items: [ + { + price: price, + quantity: 1, + }, + ], + coupon, + }, + ], + }, + { idempotencyKey } + ); } } } diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index 91d98d278..71b09f99c 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -103,12 +103,14 @@ export class SubscriptionService { user, recurring, redirectUrl, + idempotencyKey, plan = SubscriptionPlan.Pro, }: { user: User; plan?: SubscriptionPlan; recurring: SubscriptionRecurring; redirectUrl: string; + idempotencyKey: string; }) { const currentSubscription = await this.db.userSubscription.findUnique({ where: { @@ -121,37 +123,43 @@ export class SubscriptionService { } const price = await this.getPrice(plan, recurring); - const customer = await this.getOrCreateCustomer(user); + const customer = await this.getOrCreateCustomer(idempotencyKey, user); const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess); - return await this.stripe.checkout.sessions.create({ - line_items: [ - { - price, - quantity: 1, + return await this.stripe.checkout.sessions.create( + { + line_items: [ + { + price, + quantity: 1, + }, + ], + tax_id_collection: { + enabled: true, + }, + ...(coupon + ? { + discounts: [{ coupon }], + } + : { + allow_promotion_codes: true, + }), + mode: 'subscription', + success_url: redirectUrl, + customer: customer.stripeCustomerId, + customer_update: { + address: 'auto', + name: 'auto', }, - ], - tax_id_collection: { - enabled: true, }, - ...(coupon - ? { - discounts: [{ coupon }], - } - : { - allow_promotion_codes: true, - }), - mode: 'subscription', - success_url: redirectUrl, - customer: customer.stripeCustomerId, - customer_update: { - address: 'auto', - name: 'auto', - }, - }); + { idempotencyKey } + ); } - async cancelSubscription(userId: string): Promise { + async cancelSubscription( + idempotencyKey: string, + userId: string + ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, @@ -174,7 +182,7 @@ export class SubscriptionService { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); - await manager.cancel(); + await manager.cancel(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( @@ -187,15 +195,17 @@ export class SubscriptionService { // see https://stripe.com/docs/billing/subscriptions/cancel const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, - { - cancel_at_period_end: true, - } + { cancel_at_period_end: true }, + { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } - async resumeCanceledSubscription(userId: string): Promise { + async resumeCanceledSubscription( + idempotencyKey: string, + userId: string + ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, @@ -221,7 +231,7 @@ export class SubscriptionService { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); - await manager.resume(); + await manager.resume(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( @@ -232,9 +242,8 @@ export class SubscriptionService { } else { const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, - { - cancel_at_period_end: false, - } + { cancel_at_period_end: false }, + { idempotencyKey } ); return await this.saveSubscription(user, subscription); @@ -242,6 +251,7 @@ export class SubscriptionService { } async updateSubscriptionRecurring( + idempotencyKey: string, userId: string, recurring: SubscriptionRecurring ): Promise { @@ -272,10 +282,12 @@ export class SubscriptionService { ); const manager = await this.scheduleManager.fromSubscription( + idempotencyKey, user.subscription.stripeSubscriptionId ); await manager.update( + idempotencyKey, price, // if user is early access user, use early access coupon manager.currentPhase?.coupon === CouponType.EarlyAccess || @@ -355,10 +367,16 @@ export class SubscriptionService { // deal with early access user if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) { + const idempotencyKey = stripeInvoice.id + '_earlyaccess'; const manager = await this.scheduleManager.fromSubscription( + idempotencyKey, line.subscription as string ); - await manager.update(line.price.id, CouponType.EarlyAccessRenew); + await manager.update( + idempotencyKey, + line.price.id, + CouponType.EarlyAccessRenew + ); } } @@ -530,7 +548,10 @@ export class SubscriptionService { } } - private async getOrCreateCustomer(user: User): Promise { + private async getOrCreateCustomer( + idempotencyKey: string, + user: User + ): Promise { const customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, @@ -550,9 +571,10 @@ export class SubscriptionService { if (stripeCustomersList.data.length) { stripeCustomer = stripeCustomersList.data[0]; } else { - stripeCustomer = await this.stripe.customers.create({ - email: user.email, - }); + stripeCustomer = await this.stripe.customers.create( + { email: user.email }, + { idempotencyKey } + ); } return await this.db.userStripeCustomer.create({ diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 5b62bdaa0..dc68b19d1 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -278,13 +278,13 @@ type Mutation { addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! """Create a subscription checkout link of stripe""" - checkout(recurring: SubscriptionRecurring!): String! + checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String! """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! - cancelSubscription: UserSubscription! - resumeSubscription: UserSubscription! - updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription! + cancelSubscription(idempotencyKey: String!): UserSubscription! + resumeSubscription(idempotencyKey: String!): UserSubscription! + updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription! } """The `Upload` scalar type represents a file upload.""" diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index ec4ef7236..bb417f329 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -21,6 +21,7 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql'; import { ArrowRightSmallIcon } from '@blocksuite/icons'; import { Button, IconButton } from '@toeverything/components/button'; import { useSetAtom } from 'jotai'; +import { nanoid } from 'nanoid'; import { Suspense, useCallback, useMemo, useState } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; @@ -89,13 +90,19 @@ const SubscriptionSettings = () => { }); const [openCancelModal, setOpenCancelModal] = useState(false); + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + const cancel = useCallback(() => { - trigger(null, { - onSuccess: data => { - mutateSubscription(data.cancelSubscription); - }, - }); - }, [trigger, mutateSubscription]); + trigger( + { idempotencyKey }, + { + onSuccess: data => { + mutateSubscription(data.cancelSubscription); + }, + } + ); + }, [trigger, idempotencyKey, mutateSubscription]); const { data: pricesQueryResult } = useQuery({ query: pricesQuery, @@ -288,13 +295,19 @@ const ResumeSubscription = ({ mutation: resumeSubscriptionMutation, }); + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + const resume = useCallback(() => { - trigger(null, { - onSuccess: data => { - onSubscriptionUpdate(data.resumeSubscription); - }, - }); - }, [trigger, onSubscriptionUpdate]); + trigger( + { idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.resumeSubscription); + }, + } + ); + }, [trigger, idempotencyKey, onSubscriptionUpdate]); return (