From 89cc9b072b94d220448f55411a85871c36a857b0 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:25:43 +0800 Subject: [PATCH] feat(server): early subscription for iap (#13826) ## Summary by CodeRabbit * **New Features** * Added a mutation to request/apply a subscription by transaction ID (client mutation and server operation), returning subscription details. * **Bug Fixes / Improvements** * More robust external subscription sync with safer conflict detection, optional short-lived confirmation, improved parsing and error logging. * **Chores** * Standardized time constants for clarity. * **Tests** * Updated subscription test data (expiration date) to reflect new lifecycle expectations. --- .../src/__tests__/payment/revenuecat.spec.ts | 2 +- .../backend/server/src/base/utils/unit.ts | 3 +- .../server/src/plugins/payment/resolver.ts | 51 +++++++++ .../src/plugins/payment/revenuecat/service.ts | 104 ++++++++++++------ .../src/plugins/payment/revenuecat/webhook.ts | 81 +++++++++++--- packages/backend/server/src/schema.gql | 3 + packages/common/graphql/src/graphql/index.ts | 19 ++++ .../src/graphql/subscription-request.gql | 13 +++ packages/common/graphql/src/schema.ts | 31 ++++++ 9 files changed, 256 insertions(+), 51 deletions(-) create mode 100644 packages/common/graphql/src/graphql/subscription-request.gql diff --git a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts index 33f304543..798e92783 100644 --- a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -497,7 +497,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing isTrial: false, isActive: false, latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'), - expirationDate: new Date('2024-01-01T00:00:00.000Z'), + expirationDate: new Date('2025-04-08T00:00:00.000Z'), productId: 'app.affine.pro.Annual', store: 'app_store', willRenew: false, diff --git a/packages/backend/server/src/base/utils/unit.ts b/packages/backend/server/src/base/utils/unit.ts index f0798822b..2605c46e9 100644 --- a/packages/backend/server/src/base/utils/unit.ts +++ b/packages/backend/server/src/base/utils/unit.ts @@ -1,4 +1,5 @@ export const OneKB = 1024; export const OneMB = OneKB * OneKB; export const OneGB = OneKB * OneMB; -export const OneDay = 1000 * 60 * 60 * 24; +export const OneMinute = 1000 * 60; +export const OneDay = OneMinute * 60 * 24; diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 4e492bc94..d6a93f402 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -22,6 +22,7 @@ import { AccessDenied, AuthenticationRequired, FailedToCheckout, + InvalidSubscriptionParameters, Throttle, WorkspaceIdRequiredToUpdateTeamSubscription, } from '../../base'; @@ -543,6 +544,56 @@ export class UserSubscriptionResolver { }); } + @Throttle('strict') + @Mutation(() => [SubscriptionType], { + description: 'Request to apply the subscription in advance', + }) + async requestApplySubscription( + @CurrentUser() user: CurrentUser, + @Args('transactionId') transactionId: string + ): Promise { + if (!user) { + throw new AuthenticationRequired(); + } + + let existsSubscription = await this.db.subscription.findFirst({ + where: { rcExternalRef: transactionId }, + }); + + // subscription with the transactionId already exists + if (existsSubscription) { + if (existsSubscription.targetId !== user.id) { + throw new InvalidSubscriptionParameters(); + } else { + this.normalizeSubscription(existsSubscription); + return [existsSubscription]; + } + } + + let current: Subscription[] = []; + + try { + await this.rcHandler.syncAppUserWithExternalRef(user.id, transactionId); + current = await this.db.subscription.findMany({ + where: { + targetId: user.id, + status: { + in: [ + SubscriptionStatus.Active, + SubscriptionStatus.Trialing, + SubscriptionStatus.PastDue, + ], + }, + }, + }); + // ignore errors + } catch {} + + current.forEach(subscription => this.normalizeSubscription(subscription)); + + return current; + } + @Throttle('strict') @Mutation(() => [SubscriptionType], { description: 'Refresh current user subscriptions and return latest.', diff --git a/packages/backend/server/src/plugins/payment/revenuecat/service.ts b/packages/backend/server/src/plugins/payment/revenuecat/service.ts index 6643cbb16..64e6bd2f8 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/service.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/service.ts @@ -18,6 +18,7 @@ const Store = z.enum([ const zRcV2RawProduct = z .object({ id: z.string().nonempty(), + display_name: z.string().nonempty(), store_identifier: z.string().nonempty(), subscription: z .object({ duration: z.string().nullable() }) @@ -165,6 +166,43 @@ export class RevenueCatService { return null; } + async getSubscriptionByExternalRef( + externalRef: string + ): Promise { + const res = await fetch( + `https://api.revenuecat.com/v2/projects/${this.projectId}/subscriptions?store_subscription_identifier=${encodeURIComponent(externalRef)}`, + { + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `RevenueCat getSubscriber failed: ${res.status} ${res.statusText} - ${text}` + ); + } + + const json = await res.json(); + const envParsed = zRcV2RawEnvelope.safeParse(json); + + if (envParsed.success) { + const parsedSubs = await Promise.all( + envParsed.data.items.flatMap(async sub => this.parseSubscription(sub)) + ); + return parsedSubs.filter((s): s is Subscription => s !== null); + } + this.logger.error( + `RevenueCat subscription parse failed: ${JSON.stringify( + envParsed.error.format() + )}` + ); + return null; + } + async getSubscriptions(customerId: string): Promise { const res = await fetch( `https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`, @@ -188,38 +226,7 @@ export class RevenueCatService { if (envParsed.success) { const parsedSubs = await Promise.all( - envParsed.data.items.flatMap(async sub => { - const items = sub.entitlements.items ?? []; - const products = ( - await Promise.all(items.map(this.getProducts.bind(this))) - ) - .filter((p): p is Product[] => p !== null) - .flat(); - const product = products.find(p => p.id === sub.product_id); - if (!product) { - this.logger.warn( - `RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}` - ); - return null; - } - - return { - identifier: product.display_name, - isTrial: sub.status === 'trialing', - isActive: - sub.gives_access === true || - sub.status === 'active' || - sub.status === 'trialing', - latestPurchaseDate: sub.starts_at ? new Date(sub.starts_at) : null, - expirationDate: sub.current_period_ends_at - ? new Date(sub.current_period_ends_at) - : null, - productId: product.store_identifier, - store: sub.store ?? product.app?.type, - willRenew: sub.auto_renewal_status === 'will_renew', - duration: product.subscription?.duration ?? null, - }; - }) + envParsed.data.items.flatMap(async sub => this.parseSubscription(sub)) ); return parsedSubs.filter((s): s is Subscription => s !== null); } @@ -230,4 +237,37 @@ export class RevenueCatService { ); return null; } + + private async parseSubscription( + sub: z.infer + ): Promise { + const items = sub.entitlements.items ?? []; + const products = (await Promise.all(items.map(this.getProducts.bind(this)))) + .filter((p): p is Product[] => p !== null) + .flat(); + const product = products.find(p => p.id === sub.product_id); + if (!product) { + this.logger.warn( + `RevenueCat subscription ${sub.id} missing product for product_id=${sub.product_id}` + ); + return null; + } + + return { + identifier: product.display_name, + isTrial: sub.status === 'trialing', + isActive: + sub.gives_access === true || + sub.status === 'active' || + sub.status === 'trialing', + latestPurchaseDate: sub.starts_at ? new Date(sub.starts_at) : null, + expirationDate: sub.current_period_ends_at + ? new Date(sub.current_period_ends_at) + : null, + productId: product.store_identifier, + store: sub.store ?? product.app?.type, + willRenew: sub.auto_renewal_status === 'will_renew', + duration: product.subscription?.duration ?? null, + }; + } } diff --git a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts index 0aa066a71..7b2ea214d 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { IapStore, PrismaClient, Provider } from '@prisma/client'; -import { Config, EventBus, OnEvent } from '../../../base'; +import { Config, EventBus, OneMinute, OnEvent } from '../../../base'; import { SubscriptionStatus } from '../types'; import { RcEvent } from './controller'; import { resolveProductMapping } from './map'; @@ -30,6 +30,33 @@ export class RevenueCatWebhookHandler { await this.syncAppUser(appUserId, evt.event); } + // NOTE: add subscription to user before the subscription event is received + // will expire after a short duration if not confirmed by webhook + async syncAppUserWithExternalRef(appUserId: string, externalRef: string) { + // Pull latest state to be resilient to reorder/duplicate events + let subscriptions: Awaited< + ReturnType + >; + try { + subscriptions = await this.rc.getSubscriptionByExternalRef(externalRef); + if (!subscriptions) return; + } catch (e) { + this.logger.error( + `Failed to fetch RC subscriptions for ${appUserId} by ${externalRef}`, + e + ); + return; + } + + await this.syncSubscription( + appUserId, + subscriptions, + undefined, + externalRef, + new Date(Date.now() + 10 * OneMinute) // expire after 10 minutes + ); + } + // Exposed for reuse by reconcile job async syncAppUser(appUserId: string, event?: RcEvent) { // Pull latest state to be resilient to reorder/duplicate events @@ -40,10 +67,20 @@ export class RevenueCatWebhookHandler { subscriptions = await this.rc.getSubscriptions(appUserId); if (!subscriptions) return; } catch (e) { - this.logger.error(`Failed to fetch RC subscriber for ${appUserId}`, e); + this.logger.error(`Failed to fetch RC subscription for ${appUserId}`, e); return; } + await this.syncSubscription(appUserId, subscriptions, event); + } + + private async syncSubscription( + appUserId: string, + subscriptions: Subscription[], + event?: RcEvent, + externalRef?: string, + overrideExpirationDate?: Date + ) { const productOverride = this.config.payment.revenuecat?.productMap; for (const sub of subscriptions) { @@ -51,27 +88,39 @@ export class RevenueCatWebhookHandler { // ignore non-whitelisted and non-fallbackable products if (!mapping) continue; - const { status, deleteInstead, canceledAt, iapStore } = - this.mapStatus(sub); + const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus( + sub, + overrideExpirationDate + ); - const rcExternalRef = this.pickExternalRef(event); + const rcExternalRef = externalRef || this.pickExternalRef(event); + // Upsert by unique (targetId, plan) for idempotency + const start = sub.latestPurchaseDate || new Date(); + const end = overrideExpirationDate || sub.expirationDate || null; + const nextBillAt = end; // period end serves as next bill anchor for IAP // Mutual exclusion: skip if Stripe already active for the same plan const conflict = await this.db.subscription.findFirst({ where: { targetId: appUserId, plan: mapping.plan, - provider: Provider.stripe, status: { in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], }, }, }); if (conflict) { - this.logger.warn( - `Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}` - ); - continue; + if (conflict.provider === Provider.stripe) { + this.logger.warn( + `Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}` + ); + continue; + } else if (conflict.end && end && conflict.end > end) { + this.logger.warn( + `Skip RC upsert: newer subscription exists. user=${appUserId} plan=${mapping.plan}` + ); + continue; + } } if (deleteInstead) { @@ -93,11 +142,6 @@ export class RevenueCatWebhookHandler { continue; } - // Upsert by unique (targetId, plan) for idempotency - const start = sub.latestPurchaseDate || new Date(); - const end = sub.expirationDate || null; - const nextBillAt = end; // period end serves as next bill anchor for IAP - await this.db.subscription.upsert({ where: { targetId_plan: { targetId: appUserId, plan: mapping.plan }, @@ -172,7 +216,10 @@ export class RevenueCatWebhookHandler { ); } - private mapStatus(sub: Subscription): { + private mapStatus( + sub: Subscription, + overrideExpirationDate?: Date + ): { status: SubscriptionStatus; iapStore: IapStore | null; deleteInstead: boolean; @@ -189,7 +236,7 @@ export class RevenueCatWebhookHandler { : null; if (sub.isActive) { - if (sub.isTrial) { + if (sub.isTrial || overrideExpirationDate) { return { iapStore, status: SubscriptionStatus.Trialing, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index b1f818054..388eee579 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -1323,6 +1323,9 @@ type Mutation { removeWorkspaceEmbeddingFiles(fileId: String!, workspaceId: String!): Boolean! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! + """Request to apply the subscription in advance""" + requestApplySubscription(transactionId: String!): [SubscriptionType!]! + """Resolve a comment or not""" resolveComment(input: CommentResolveInput!): Boolean! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 31e46ebc8..a7f2c8538 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -2237,6 +2237,25 @@ export const refreshSubscriptionMutation = { deprecations: ["'id' is deprecated: removed"], }; +export const requestApplySubscriptionMutation = { + id: 'requestApplySubscriptionMutation' as const, + op: 'requestApplySubscription', + query: `mutation requestApplySubscription($transactionId: String!) { + requestApplySubscription(transactionId: $transactionId) { + id + status + plan + recurring + start + end + nextBillAt + canceledAt + variant + } +}`, + deprecations: ["'id' is deprecated: removed"], +}; + export const subscriptionQuery = { id: 'subscriptionQuery' as const, op: 'subscription', diff --git a/packages/common/graphql/src/graphql/subscription-request.gql b/packages/common/graphql/src/graphql/subscription-request.gql new file mode 100644 index 000000000..a25abb132 --- /dev/null +++ b/packages/common/graphql/src/graphql/subscription-request.gql @@ -0,0 +1,13 @@ +mutation requestApplySubscription($transactionId: String!) { + requestApplySubscription(transactionId: $transactionId) { + id + status + plan + recurring + start + end + nextBillAt + canceledAt + variant + } +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 5a94ce803..f774e9bc3 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -1467,6 +1467,8 @@ export interface Mutation { /** Remove workspace embedding files */ removeWorkspaceEmbeddingFiles: Scalars['Boolean']['output']; removeWorkspaceFeature: Scalars['Boolean']['output']; + /** Request to apply the subscription in advance */ + requestApplySubscription: Array; /** Resolve a comment or not */ resolveComment: Scalars['Boolean']['output']; resumeSubscription: SubscriptionType; @@ -1788,6 +1790,10 @@ export interface MutationRemoveWorkspaceFeatureArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRequestApplySubscriptionArgs { + transactionId: Scalars['String']['input']; +} + export interface MutationResolveCommentArgs { input: CommentResolveInput; } @@ -6018,6 +6024,26 @@ export type RefreshSubscriptionMutation = { }>; }; +export type RequestApplySubscriptionMutationVariables = Exact<{ + transactionId: Scalars['String']['input']; +}>; + +export type RequestApplySubscriptionMutation = { + __typename?: 'Mutation'; + requestApplySubscription: Array<{ + __typename?: 'SubscriptionType'; + id: string | null; + status: SubscriptionStatus; + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + start: string; + end: string | null; + nextBillAt: string | null; + canceledAt: string | null; + variant: SubscriptionVariant | null; + }>; +}; + export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>; export type SubscriptionQuery = { @@ -7108,6 +7134,11 @@ export type Mutations = variables: RefreshSubscriptionMutationVariables; response: RefreshSubscriptionMutation; } + | { + name: 'requestApplySubscriptionMutation'; + variables: RequestApplySubscriptionMutationVariables; + response: RequestApplySubscriptionMutation; + } | { name: 'updateDocDefaultRoleMutation'; variables: UpdateDocDefaultRoleMutationVariables;