mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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',
|
||||
|
||||
13
packages/common/graphql/src/graphql/subscription-request.gql
Normal file
13
packages/common/graphql/src/graphql/subscription-request.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
mutation requestApplySubscription($transactionId: String!) {
|
||||
requestApplySubscription(transactionId: $transactionId) {
|
||||
id
|
||||
status
|
||||
plan
|
||||
recurring
|
||||
start
|
||||
end
|
||||
nextBillAt
|
||||
canceledAt
|
||||
variant
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user