mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat: sync rcat data (#13628)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * RevenueCat support: public webhook endpoint, webhook handler/service, nightly reconciliation and per-user sync; subscriptions now expose provider and iapStore; new user-facing error for App Store/Play-managed subscriptions. * **Chores** * Multi-provider subscription schema (Provider, IapStore); Stripe credentials moved into payment.stripe (top-level apiKey/webhookKey deprecated); new payment.revenuecat config and defaults added. * **Tests** * Comprehensive RevenueCat integration test suite and snapshots. * **Documentation** * Admin config descriptions updated with deprecation guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -9,6 +9,13 @@ export interface PaymentStartupConfig {
|
||||
webhookKey: string;
|
||||
};
|
||||
} & Stripe.StripeConfig;
|
||||
revenuecat?: {
|
||||
apiKey?: string;
|
||||
webhookAuth?: string;
|
||||
enabled?: boolean;
|
||||
environment?: 'sandbox' | 'production';
|
||||
productMap?: Record<string, { plan: string; recurring: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PaymentRuntimeConfig {
|
||||
@@ -20,9 +27,36 @@ declare global {
|
||||
payment: {
|
||||
enabled: boolean;
|
||||
showLifetimePrice: boolean;
|
||||
/**
|
||||
* @deprecated use payment.stripe.apiKey
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @deprecated use payment.stripe.webhookKey
|
||||
*/
|
||||
webhookKey: string;
|
||||
stripe: ConfigItem<{} & Stripe.StripeConfig>;
|
||||
stripe: ConfigItem<
|
||||
{
|
||||
/** Preferred place for Stripe API key */
|
||||
apiKey?: string;
|
||||
/** Preferred place for Stripe Webhook key */
|
||||
webhookKey?: string;
|
||||
} & Stripe.StripeConfig
|
||||
>;
|
||||
revenuecat: ConfigItem<{
|
||||
/** Whether enable RevenueCat integration */
|
||||
enabled?: boolean;
|
||||
/** RevenueCat REST API Key */
|
||||
apiKey?: string;
|
||||
/** RevenueCat Project Id */
|
||||
projectId?: string;
|
||||
/** Authorization header value required by webhook */
|
||||
webhookAuth?: string;
|
||||
/** RC environment */
|
||||
environment?: 'sandbox' | 'production';
|
||||
/** Product whitelist mapping: productId -> { plan, recurring } */
|
||||
productMap?: Record<string, { plan: string; recurring: string }>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -37,18 +71,33 @@ defineModuleConfig('payment', {
|
||||
default: true,
|
||||
},
|
||||
apiKey: {
|
||||
desc: 'Stripe API key to enable payment service.',
|
||||
desc: '[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.',
|
||||
default: '',
|
||||
env: 'STRIPE_API_KEY',
|
||||
},
|
||||
webhookKey: {
|
||||
desc: 'Stripe webhook key to enable payment service.',
|
||||
desc: '[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.',
|
||||
default: '',
|
||||
env: 'STRIPE_WEBHOOK_KEY',
|
||||
},
|
||||
stripe: {
|
||||
desc: 'Stripe sdk options',
|
||||
default: {},
|
||||
desc: 'Stripe sdk options and credentials',
|
||||
default: {
|
||||
apiKey: '',
|
||||
webhookKey: '',
|
||||
},
|
||||
link: 'https://docs.stripe.com/api',
|
||||
},
|
||||
revenuecat: {
|
||||
desc: 'RevenueCat integration configs',
|
||||
default: {
|
||||
enabled: false,
|
||||
apiKey: '',
|
||||
projectId: '',
|
||||
webhookAuth: '',
|
||||
environment: 'production',
|
||||
productMap: {},
|
||||
},
|
||||
link: 'https://www.revenuecat.com/docs/',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@ export class StripeWebhookController {
|
||||
@Public()
|
||||
@Post('/webhook')
|
||||
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
||||
const webhookKey = this.config.payment.webhookKey;
|
||||
const nestedWebhookKey = this.config.payment.stripe?.webhookKey;
|
||||
const legacyWebhookKey = this.config.payment.webhookKey;
|
||||
const webhookKey = nestedWebhookKey || legacyWebhookKey || '';
|
||||
// Retrieve the event by verifying the signature using the raw body and secret.
|
||||
const signature = req.headers['stripe-signature'];
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { EventBus, JobQueue, OnJob } from '../../base';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
SubscriptionVariant,
|
||||
} from './types';
|
||||
|
||||
@@ -13,6 +15,8 @@ declare global {
|
||||
interface Jobs {
|
||||
'nightly.cleanExpiredOnetimeSubscriptions': {};
|
||||
'nightly.notifyAboutToExpireWorkspaceSubscriptions': {};
|
||||
'nightly.reconcileRevenueCatSubscriptions': {};
|
||||
'nightly.revenuecat.syncUser': { userId: string };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +25,8 @@ export class SubscriptionCronJobs {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly event: EventBus,
|
||||
private readonly queue: JobQueue
|
||||
private readonly queue: JobQueue,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler
|
||||
) {}
|
||||
|
||||
private getDateRange(after: number, base: number | Date = Date.now()) {
|
||||
@@ -45,6 +50,12 @@ export class SubscriptionCronJobs {
|
||||
}
|
||||
);
|
||||
|
||||
await this.queue.add(
|
||||
'nightly.reconcileRevenueCatSubscriptions',
|
||||
{},
|
||||
{ jobId: 'nightly-payment-reconcile-revenuecat-subscriptions' }
|
||||
);
|
||||
|
||||
// FIXME(@forehalo): the strategy is totally wrong, for monthly plan. redesign required
|
||||
// await this.queue.add(
|
||||
// 'nightly.notifyAboutToExpireWorkspaceSubscriptions',
|
||||
@@ -142,4 +153,41 @@ export class SubscriptionCronJobs {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('nightly.reconcileRevenueCatSubscriptions')
|
||||
async reconcileRevenueCatSubscriptions() {
|
||||
// Find active/trialing/past_due RC subscriptions and resync via RC REST
|
||||
const subs = await this.db.subscription.findMany({
|
||||
where: {
|
||||
provider: Provider.revenuecat,
|
||||
status: {
|
||||
in: [
|
||||
SubscriptionStatus.Active,
|
||||
SubscriptionStatus.Trialing,
|
||||
SubscriptionStatus.PastDue,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { targetId: true },
|
||||
});
|
||||
|
||||
// de-duplicate targetIds
|
||||
const userIds = Array.from(new Set(subs.map(s => s.targetId)));
|
||||
for (const userId of userIds) {
|
||||
await this.queue.add(
|
||||
'nightly.revenuecat.syncUser',
|
||||
{ userId },
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 60_000 },
|
||||
jobId: `nightly-rc-sync-${userId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('nightly.revenuecat.syncUser')
|
||||
async reconcileRevenueCatSubscriptionOfUser(payload: { userId: string }) {
|
||||
await this.rcHandler.syncAppUser(payload.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
UserSubscriptionResolver,
|
||||
WorkspaceSubscriptionResolver,
|
||||
} from './resolver';
|
||||
import {
|
||||
RevenueCatService,
|
||||
RevenueCatWebhookController,
|
||||
RevenueCatWebhookHandler,
|
||||
} from './revenuecat';
|
||||
import { SubscriptionService } from './service';
|
||||
import { StripeFactory, StripeProvider } from './stripe';
|
||||
import { StripeWebhook } from './webhook';
|
||||
@@ -40,10 +45,12 @@ import { StripeWebhook } from './webhook';
|
||||
providers: [
|
||||
StripeFactory,
|
||||
StripeProvider,
|
||||
RevenueCatService,
|
||||
SubscriptionService,
|
||||
SubscriptionResolver,
|
||||
UserSubscriptionResolver,
|
||||
StripeWebhook,
|
||||
RevenueCatWebhookHandler,
|
||||
UserSubscriptionManager,
|
||||
WorkspaceSubscriptionManager,
|
||||
SelfhostTeamSubscriptionManager,
|
||||
@@ -51,6 +58,10 @@ import { StripeWebhook } from './webhook';
|
||||
WorkspaceSubscriptionResolver,
|
||||
PaymentEventHandlers,
|
||||
],
|
||||
controllers: [StripeWebhookController, LicenseController],
|
||||
controllers: [
|
||||
StripeWebhookController,
|
||||
LicenseController,
|
||||
RevenueCatWebhookController,
|
||||
],
|
||||
})
|
||||
export class PaymentModule {}
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface Subscription {
|
||||
trialEnd: Date | null;
|
||||
nextBillAt: Date | null;
|
||||
canceledAt: Date | null;
|
||||
// read-only metadata for IAP integration
|
||||
provider?: string | null;
|
||||
iapStore?: string | null;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
|
||||
import { pick } from 'lodash-es';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
||||
@@ -132,8 +132,9 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
||||
const [subscription] = await this.db.$transaction([
|
||||
this.db.subscription.create({
|
||||
data: {
|
||||
provider: Provider.stripe,
|
||||
targetId: key,
|
||||
...subscriptionData,
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
}),
|
||||
this.db.license.create({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidCheckoutParameters,
|
||||
ManagedByAppStoreOrPlay,
|
||||
Mutex,
|
||||
OnEvent,
|
||||
SubscriptionAlreadyExists,
|
||||
@@ -103,6 +104,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
throw new InvalidCheckoutParameters();
|
||||
}
|
||||
|
||||
const active = await this.getActiveSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
});
|
||||
if (active?.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
const subscription = await this.getSubscription({
|
||||
plan: lookupKey.plan,
|
||||
userId: user.id,
|
||||
@@ -256,7 +265,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
]),
|
||||
create: {
|
||||
targetId: userId,
|
||||
...subscriptionData,
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
|
||||
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||
import { omit, pick } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -157,6 +157,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
update: {
|
||||
@@ -171,7 +172,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
},
|
||||
create: {
|
||||
targetId: workspaceId,
|
||||
...subscriptionData,
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,6 +108,23 @@ export class SubscriptionType implements Partial<Subscription> {
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
|
||||
// read-only fields for display purpose
|
||||
// provider: 'stripe' | 'revenuecat'
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Payment provider of this subscription. Read-only. One of: stripe | revenuecat',
|
||||
})
|
||||
provider?: string | null;
|
||||
|
||||
// iapStore: 'app_store' | 'play_store' | null when provider is stripe
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'If provider is revenuecat, indicates underlying store. Read-only. One of: app_store | play_store',
|
||||
})
|
||||
iapStore?: string | null;
|
||||
|
||||
// deprecated fields
|
||||
@Field(() => String, {
|
||||
name: 'id',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Body, Controller, Headers, Logger, Post } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config, EventBus } from '../../../base';
|
||||
import { Public } from '../../../core/auth';
|
||||
|
||||
const RcEventSchema = z
|
||||
.object({
|
||||
type: z.enum([
|
||||
'TEST',
|
||||
'INITIAL_PURCHASE',
|
||||
'NON_RENEWING_PURCHASE',
|
||||
'RENEWAL',
|
||||
'PRODUCT_CHANGE',
|
||||
'CANCELLATION',
|
||||
'BILLING_ISSUE',
|
||||
'SUBSCRIBER_ALIAS',
|
||||
'SUBSCRIPTION_PAUSED',
|
||||
'UNCANCELLATION',
|
||||
'TRANSFER',
|
||||
'SUBSCRIPTION_EXTENDED',
|
||||
'EXPIRATION',
|
||||
'TEMPORARY_ENTITLEMENT_GRANT',
|
||||
'INVOICE_ISSUANCE',
|
||||
'VIRTUAL_CURRENCY_TRANSACTION',
|
||||
]),
|
||||
id: z.string(),
|
||||
app_id: z.string(),
|
||||
environment: z.enum(['PRODUCTION', 'SANDBOX']),
|
||||
|
||||
app_user_id: z.string().optional(),
|
||||
store: z.string().optional(),
|
||||
is_family_share: z.boolean().optional(),
|
||||
period_type: z.string().optional(),
|
||||
original_transaction_id: z.string().optional(),
|
||||
transaction_id: z.string().optional(),
|
||||
purchase_token: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RcWebhookPayloadSchema = z.object({ event: RcEventSchema }).passthrough();
|
||||
|
||||
export type RcEvent = z.infer<typeof RcEventSchema>;
|
||||
type RcPayload = z.infer<typeof RcWebhookPayloadSchema>;
|
||||
|
||||
@Controller('/api/revenuecat')
|
||||
export class RevenueCatWebhookController {
|
||||
private readonly logger = new Logger(RevenueCatWebhookController.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('/webhook')
|
||||
async handleWebhook(
|
||||
@Body() body: RcPayload,
|
||||
@Headers('authorization') authorization?: string
|
||||
) {
|
||||
const { enabled, webhookAuth, environment } =
|
||||
this.config.payment.revenuecat || {};
|
||||
if (enabled) {
|
||||
if (webhookAuth && authorization === webhookAuth) {
|
||||
try {
|
||||
const parsed = RcWebhookPayloadSchema.safeParse(body);
|
||||
if (parsed.success) {
|
||||
const event = parsed.data.event;
|
||||
const { id, app_user_id: appUserId, type } = event;
|
||||
if (
|
||||
event.environment.toLowerCase() === environment?.toLowerCase()
|
||||
) {
|
||||
this.logger.log(
|
||||
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.`
|
||||
);
|
||||
|
||||
if (appUserId && !event.is_family_share) {
|
||||
// immediately ack and process asynchronously
|
||||
this.event
|
||||
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||
.catch((e: Error) => {
|
||||
this.logger.error(
|
||||
'Failed to handle RevenueCat Webhook event.',
|
||||
e
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
'RevenueCat webhook invalid payload received.',
|
||||
parsed.error
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('RevenueCat webhook error', e as Error);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('RevenueCat webhook unauthorized.');
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { type RcEvent, RevenueCatWebhookController } from './controller';
|
||||
export { resolveProductMapping } from './map';
|
||||
export { RevenueCatService, type Subscription } from './service';
|
||||
export { RevenueCatWebhookHandler } from './webhook';
|
||||
@@ -0,0 +1,69 @@
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '../types';
|
||||
import { Subscription } from './service';
|
||||
|
||||
export interface ProductMapping {
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
}
|
||||
|
||||
// default whitelist mapping per PRD
|
||||
export const DEFAULT_PRODUCT_MAP: Record<string, ProductMapping> = {
|
||||
'app.affine.pro.Monthly': {
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Monthly,
|
||||
},
|
||||
'app.affine.pro.Annual': {
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
},
|
||||
'app.affine.pro.ai.Annual': {
|
||||
plan: SubscriptionPlan.AI,
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveFallbackFromEntitlement(
|
||||
entitlement: string | null | undefined,
|
||||
duration: string | null | undefined
|
||||
): ProductMapping | null {
|
||||
const ent = (entitlement || '').toLowerCase();
|
||||
const dur = (duration || '').toUpperCase();
|
||||
const isPro = ent === 'pro';
|
||||
const isAI = ent === 'ai';
|
||||
const isM = dur === 'P1M';
|
||||
const isY = dur === 'P1Y';
|
||||
if ((isPro || isAI) && (isM || isY)) {
|
||||
return {
|
||||
plan: isPro ? SubscriptionPlan.Pro : SubscriptionPlan.AI,
|
||||
recurring: isM
|
||||
? SubscriptionRecurring.Monthly
|
||||
: SubscriptionRecurring.Yearly,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveProductMapping(
|
||||
sub: Partial<Subscription>,
|
||||
override?: Record<string, { plan: string; recurring: string }>
|
||||
): ProductMapping | null {
|
||||
const { productId, identifier, duration } = sub;
|
||||
if (override && productId && productId in override) {
|
||||
const m = override[productId];
|
||||
const plan = m.plan as SubscriptionPlan;
|
||||
const recurring = m.recurring as SubscriptionRecurring;
|
||||
if (
|
||||
[SubscriptionPlan.Pro, SubscriptionPlan.AI].includes(plan) &&
|
||||
[SubscriptionRecurring.Monthly, SubscriptionRecurring.Yearly].includes(
|
||||
recurring
|
||||
)
|
||||
) {
|
||||
return { plan, recurring };
|
||||
}
|
||||
}
|
||||
return (
|
||||
(productId && DEFAULT_PRODUCT_MAP[productId]) ||
|
||||
resolveFallbackFromEntitlement(identifier, duration) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Config } from '../../../base';
|
||||
|
||||
const zRcV2RawProduct = z
|
||||
.object({
|
||||
id: z.string().nonempty(),
|
||||
store_identifier: z.string().nonempty(),
|
||||
subscription: z
|
||||
.object({ duration: z.string().nullable() })
|
||||
.partial()
|
||||
.nullable(),
|
||||
app: z
|
||||
.object({
|
||||
type: z.enum([
|
||||
'amazon',
|
||||
'app_store',
|
||||
'mac_app_store',
|
||||
'play_store',
|
||||
'stripe',
|
||||
'rc_billing',
|
||||
'roku',
|
||||
'paddle',
|
||||
]),
|
||||
})
|
||||
.partial(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEntitlementItem = z
|
||||
.object({
|
||||
lookup_key: z.string().nonempty(),
|
||||
display_name: z.string().nonempty(),
|
||||
products: z
|
||||
.object({ items: z.array(zRcV2RawProduct).default([]) })
|
||||
.partial()
|
||||
.nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEntitlements = z
|
||||
.object({ items: z.array(zRcV2RawEntitlementItem).default([]) })
|
||||
.partial();
|
||||
|
||||
const zRcV2RawSubscription = z
|
||||
.object({
|
||||
object: z.enum(['subscription']),
|
||||
entitlements: zRcV2RawEntitlements,
|
||||
starts_at: z.number(),
|
||||
current_period_ends_at: z.number().nullable(),
|
||||
store: z.string(),
|
||||
auto_renewal_status: z.enum([
|
||||
'will_renew',
|
||||
'will_not_renew',
|
||||
'will_change_product',
|
||||
'will_pause',
|
||||
'requires_price_increase_consent',
|
||||
'has_already_renewed',
|
||||
]),
|
||||
status: z.enum([
|
||||
'trialing',
|
||||
'active',
|
||||
'expired',
|
||||
'in_grace_period',
|
||||
'in_billing_retry',
|
||||
'paused',
|
||||
'unknown',
|
||||
'incomplete',
|
||||
]),
|
||||
gives_access: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const zRcV2RawEnvelope = z
|
||||
.object({
|
||||
app_user_id: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
subscriptions: z.array(zRcV2RawSubscription).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
// v2 minimal, simplified structure exposed to callers
|
||||
export const Subscription = z.object({
|
||||
identifier: z.string(),
|
||||
isActive: z.boolean(),
|
||||
latestPurchaseDate: z.date().nullable(),
|
||||
expirationDate: z.date().nullable(),
|
||||
productId: z.string(),
|
||||
store: z.string(),
|
||||
willRenew: z.boolean(),
|
||||
duration: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type Subscription = z.infer<typeof Subscription>;
|
||||
|
||||
@Injectable()
|
||||
export class RevenueCatService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
private get apiKey(): string {
|
||||
const key = this.config.payment.revenuecat?.apiKey;
|
||||
if (!key) {
|
||||
throw new Error('RevenueCat API key is not configured');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private get projectId(): string {
|
||||
const id = this.config.payment.revenuecat?.projectId;
|
||||
if (!id) {
|
||||
throw new Error('RevenueCat Project ID is not configured');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
||||
const res = await fetch(
|
||||
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
||||
{
|
||||
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 envParsed = zRcV2RawEnvelope.safeParse(await res.json());
|
||||
|
||||
if (envParsed.success) {
|
||||
return envParsed.data.subscriptions
|
||||
.flatMap(sub => {
|
||||
const items = sub.entitlements.items ?? [];
|
||||
return items.map(ent => {
|
||||
const product = ent.products?.items?.[0];
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
identifier: ent.lookup_key,
|
||||
isActive:
|
||||
sub.gives_access === true ||
|
||||
sub.status === 'active' ||
|
||||
sub.status === 'trialing',
|
||||
latestPurchaseDate: sub.starts_at
|
||||
? new Date(sub.starts_at * 1000)
|
||||
: null,
|
||||
expirationDate: sub.current_period_ends_at
|
||||
? new Date(sub.current_period_ends_at * 1000)
|
||||
: null,
|
||||
productId: product.store_identifier,
|
||||
store: sub.store ?? product.app.type,
|
||||
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||
duration: product.subscription?.duration ?? null,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((s): s is Subscription => s !== null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { Config, EventBus, OnEvent } from '../../../base';
|
||||
import { SubscriptionStatus } from '../types';
|
||||
import { RcEvent } from './controller';
|
||||
import { resolveProductMapping } from './map';
|
||||
import { RevenueCatService, Subscription } from './service';
|
||||
|
||||
@Injectable()
|
||||
export class RevenueCatWebhookHandler {
|
||||
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly rc: RevenueCatService,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@OnEvent('revenuecat.webhook')
|
||||
async onWebhook(evt: { appUserId?: string; event: RcEvent }) {
|
||||
if (!this.config.payment.revenuecat?.enabled) return;
|
||||
|
||||
const appUserId = evt.appUserId;
|
||||
if (!appUserId) {
|
||||
this.logger.warn('RevenueCat webhook missing appUserId');
|
||||
return;
|
||||
}
|
||||
await this.syncAppUser(appUserId, evt.event);
|
||||
}
|
||||
|
||||
// Exposed for reuse by reconcile job
|
||||
async syncAppUser(appUserId: string, event?: RcEvent) {
|
||||
// Pull latest state to be resilient to reorder/duplicate events
|
||||
let subscriptions: Awaited<
|
||||
ReturnType<RevenueCatService['getSubscriptions']>
|
||||
>;
|
||||
try {
|
||||
subscriptions = await this.rc.getSubscriptions(appUserId);
|
||||
if (!subscriptions) return;
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to fetch RC subscriber for ${appUserId}`, e);
|
||||
return;
|
||||
}
|
||||
|
||||
const productOverride = this.config.payment.revenuecat?.productMap;
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
const mapping = resolveProductMapping(sub, productOverride);
|
||||
// ignore non-whitelisted and non-fallbackable products
|
||||
if (!mapping) continue;
|
||||
|
||||
const { status, deleteInstead, canceledAt, iapStore } = this.mapStatus(
|
||||
sub,
|
||||
event
|
||||
);
|
||||
|
||||
const rcExternalRef = this.pickExternalRef(event);
|
||||
|
||||
// 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 (deleteInstead) {
|
||||
// delete record and emit cancellation if any record removed
|
||||
const result = await this.db.subscription.deleteMany({
|
||||
where: {
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
provider: Provider.revenuecat,
|
||||
},
|
||||
});
|
||||
if (result.count > 0) {
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
});
|
||||
}
|
||||
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 },
|
||||
},
|
||||
update: {
|
||||
recurring: mapping.recurring,
|
||||
variant: null,
|
||||
quantity: 1,
|
||||
stripeSubscriptionId: null,
|
||||
stripeScheduleId: null,
|
||||
provider: Provider.revenuecat,
|
||||
iapStore: iapStore,
|
||||
rcEntitlement: sub.identifier ?? null,
|
||||
rcProductId: sub.productId || null,
|
||||
rcExternalRef: rcExternalRef,
|
||||
status: status,
|
||||
start,
|
||||
end,
|
||||
nextBillAt,
|
||||
canceledAt: canceledAt ?? null,
|
||||
trialStart: null,
|
||||
trialEnd: null,
|
||||
},
|
||||
create: {
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
variant: null,
|
||||
quantity: 1,
|
||||
stripeSubscriptionId: null,
|
||||
stripeScheduleId: null,
|
||||
provider: Provider.revenuecat,
|
||||
iapStore: iapStore,
|
||||
rcEntitlement: sub.identifier ?? null,
|
||||
rcProductId: sub.productId || null,
|
||||
rcExternalRef: rcExternalRef,
|
||||
status: status,
|
||||
start,
|
||||
end,
|
||||
nextBillAt,
|
||||
canceledAt: canceledAt ?? null,
|
||||
trialStart: null,
|
||||
trialEnd: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
status === SubscriptionStatus.Active ||
|
||||
status === SubscriptionStatus.Trialing
|
||||
) {
|
||||
this.event.emit('user.subscription.activated', {
|
||||
userId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
});
|
||||
} else if (status !== SubscriptionStatus.PastDue) {
|
||||
// Do not emit canceled for PastDue (still within retry/grace window)
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: mapping.plan,
|
||||
recurring: mapping.recurring,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pickExternalRef(e?: RcEvent): string | null {
|
||||
return (
|
||||
(e &&
|
||||
(e.original_transaction_id || e.purchase_token || e.transaction_id)) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private mapStatus(
|
||||
sub: Subscription,
|
||||
event?: RcEvent
|
||||
): {
|
||||
status: SubscriptionStatus;
|
||||
iapStore: IapStore | null;
|
||||
deleteInstead: boolean;
|
||||
canceledAt?: Date | null;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const exp = sub.expirationDate?.getTime();
|
||||
const periodType = (event?.period_type || '').toLowerCase();
|
||||
const eventType = (event?.type || '').toString().toLowerCase();
|
||||
|
||||
// Determine iap store and external reference for observability
|
||||
const iapStore = this.mapIapStore(sub.store, event);
|
||||
|
||||
// Refund/chargeback/revocation should be treated as immediate expiration
|
||||
// Prioritize these event types regardless of current sub.isActive flag
|
||||
if (
|
||||
eventType.includes('refund') ||
|
||||
eventType.includes('chargeback') ||
|
||||
eventType.includes('revocation') ||
|
||||
eventType.includes('revoke')
|
||||
) {
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.Canceled,
|
||||
deleteInstead: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (sub.isActive) {
|
||||
if (periodType === 'trial') {
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.Trialing,
|
||||
deleteInstead: false,
|
||||
canceledAt: null,
|
||||
};
|
||||
}
|
||||
// PastDue from subscriber is not directly indicated; treat active as Active
|
||||
const canceledAt = sub.willRenew === false ? new Date() : null;
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.Active,
|
||||
deleteInstead: false,
|
||||
canceledAt,
|
||||
};
|
||||
}
|
||||
|
||||
// inactive: if not expired yet (grace/pastdue), keep as PastDue; otherwise delete
|
||||
if (exp && exp > now) {
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.PastDue,
|
||||
deleteInstead: false,
|
||||
canceledAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
iapStore,
|
||||
status: SubscriptionStatus.Canceled,
|
||||
deleteInstead: true,
|
||||
};
|
||||
}
|
||||
|
||||
private mapIapStore(store?: string, event?: RcEvent): IapStore | null {
|
||||
const s = (store || event?.store || '').toString().toLowerCase();
|
||||
if (!s) return null;
|
||||
if (s.includes('app') || s.includes('ios')) return IapStore.app_store;
|
||||
if (s.includes('play') || s.includes('android') || s.includes('google'))
|
||||
return IapStore.play_store;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
InvalidLicenseSessionId,
|
||||
InvalidSubscriptionParameters,
|
||||
LicenseRevealed,
|
||||
ManagedByAppStoreOrPlay,
|
||||
OnEvent,
|
||||
SameSubscriptionRecurring,
|
||||
SubscriptionExpired,
|
||||
@@ -165,6 +166,11 @@ export class SubscriptionService {
|
||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||
}
|
||||
|
||||
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||
if (subscription.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (!subscription.stripeSubscriptionId) {
|
||||
throw new CantUpdateOnetimePaymentSubscription(
|
||||
'Onetime payment subscription cannot be canceled.'
|
||||
@@ -211,6 +217,11 @@ export class SubscriptionService {
|
||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||
}
|
||||
|
||||
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||
if (subscription.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (!subscription.canceledAt) {
|
||||
throw new SubscriptionHasNotBeenCanceled();
|
||||
}
|
||||
@@ -258,6 +269,11 @@ export class SubscriptionService {
|
||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||
}
|
||||
|
||||
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||
if (subscription.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (!subscription.stripeSubscriptionId) {
|
||||
throw new CantUpdateOnetimePaymentSubscription();
|
||||
}
|
||||
@@ -312,6 +328,10 @@ export class SubscriptionService {
|
||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||
}
|
||||
|
||||
if (subscription.provider === 'revenuecat') {
|
||||
throw new ManagedByAppStoreOrPlay();
|
||||
}
|
||||
|
||||
if (!subscription.stripeSubscriptionId) {
|
||||
throw new CantUpdateOnetimePaymentSubscription();
|
||||
}
|
||||
|
||||
@@ -39,15 +39,20 @@ export class StripeFactory {
|
||||
}
|
||||
|
||||
setup() {
|
||||
// TODO@(@forehalo): use per-requests api key injection
|
||||
this.#stripe = new Stripe(
|
||||
this.config.payment.apiKey ||
|
||||
// NOTE(@forehalo):
|
||||
// we always fake a key if not set because `new Stripe` will complain if it's empty string
|
||||
// this will make code cleaner than providing `Stripe` instance as optional one.
|
||||
'stripe-api-key',
|
||||
this.config.payment.stripe
|
||||
);
|
||||
// Prefer new keys under payment.stripe.*, fallback to legacy root keys for backward compatibility
|
||||
const {
|
||||
apiKey: nestedApiKey,
|
||||
webhookKey: _,
|
||||
...config
|
||||
} = this.config.payment.stripe || {};
|
||||
// NOTE:
|
||||
// we always fake a key if not set because `new Stripe` will complain if it's empty string
|
||||
// this will make code cleaner than providing `Stripe` instance as optional one.
|
||||
const apiKey =
|
||||
nestedApiKey || this.config.payment.apiKey || 'stripe-api-key';
|
||||
|
||||
// TODO@(@darkskygit): use per-requests api key injection
|
||||
this.#stripe = new Stripe(apiKey, config);
|
||||
if (this.config.payment.enabled) {
|
||||
this.server.enableFeature(ServerFeature.Payment);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import type { RcEvent } from './revenuecat';
|
||||
|
||||
export enum SubscriptionRecurring {
|
||||
Monthly = 'monthly',
|
||||
Yearly = 'yearly',
|
||||
@@ -86,6 +88,12 @@ declare global {
|
||||
'stripe.customer.subscription.created': Stripe.CustomerSubscriptionCreatedEvent;
|
||||
'stripe.customer.subscription.updated': Stripe.CustomerSubscriptionUpdatedEvent;
|
||||
'stripe.customer.subscription.deleted': Stripe.CustomerSubscriptionDeletedEvent;
|
||||
|
||||
// RevenueCat integration
|
||||
'revenuecat.webhook': {
|
||||
appUserId?: string;
|
||||
event: RcEvent;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user