feat(server): early subscription for iap (#13826)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2025-10-29 22:25:43 +08:00
committed by GitHub
parent e4b5b24fdd
commit 89cc9b072b
9 changed files with 256 additions and 51 deletions

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<Subscription[]> {
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.',

View File

@@ -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<Subscription[] | null> {
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<Subscription[] | null> {
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<typeof zRcV2RawSubscription>
): Promise<Subscription | null> {
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,
};
}
}

View File

@@ -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<RevenueCatService['getSubscriptions']>
>;
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,

View File

@@ -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!

View File

@@ -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',

View File

@@ -0,0 +1,13 @@
mutation requestApplySubscription($transactionId: String!) {
requestApplySubscription(transactionId: $transactionId) {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
variant
}
}

View File

@@ -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<SubscriptionType>;
/** 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;