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:
DarkSky
2025-09-23 16:52:23 +08:00
committed by GitHub
parent 75a6c79b2c
commit 762b702e46
31 changed files with 2059 additions and 33 deletions

View File

@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "Provider" AS ENUM ('stripe', 'revenuecat');
-- CreateEnum
CREATE TYPE "IapStore" AS ENUM ('app_store', 'play_store');
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "iap_store" "IapStore",
ADD COLUMN "provider" "Provider" NOT NULL DEFAULT 'stripe',
ADD COLUMN "rc_entitlement" VARCHAR,
ADD COLUMN "rc_external_ref" VARCHAR,
ADD COLUMN "rc_product_id" VARCHAR;

View File

@@ -749,6 +749,16 @@ model Subscription {
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// stripe schedule id
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
// subscription provider: stripe or revenuecat
provider Provider @default(stripe)
// iap store for revenuecat subscriptions
iapStore IapStore? @map("iap_store")
// revenuecat entitlement name like "Pro" / "AI"
rcEntitlement String? @map("rc_entitlement") @db.VarChar
// revenuecat product id like "app.affine.pro.Annual"
rcProductId String? @map("rc_product_id") @db.VarChar
// external reference, appstore originalTransactionId or play purchaseToken
rcExternalRef String? @map("rc_external_ref") @db.VarChar
// subscription.status, active/past_due/canceled/unpaid...
status String @db.VarChar(20)
// subscription.current_period_start
@@ -770,6 +780,16 @@ model Subscription {
@@map("subscriptions")
}
enum Provider {
stripe
revenuecat
}
enum IapStore {
app_store
play_store
}
model Invoice {
stripeInvoiceId String @id @map("stripe_invoice_id")
targetId String @map("target_id") @db.VarChar

View File

@@ -0,0 +1,253 @@
# Snapshot report for `src/__tests__/payment/revenuecat.spec.ts`
The actual snapshot is saved in `revenuecat.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should resolve product mapping consistently (whitelist, override, unknown)
> should map product for whitelist/override/unknown
{
override: {
customMonthly: {
plan: 'pro',
recurring: 'monthly',
},
},
unknown: null,
whitelist: {
aiAnnual: {
plan: 'ai',
recurring: 'yearly',
},
proAnnual: {
plan: 'pro',
recurring: 'yearly',
},
proMonthly: {
plan: 'pro',
recurring: 'monthly',
},
},
}
## should standardize RC subscriber response and upsert subscription with observability fields
> should standardize payload and have events
{
activatedCount: 1,
canceledCount: 0,
dbObservability: {
iapStore: 'app_store',
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-tx-1',
rcProductId: 'app.affine.pro.Annual',
},
lastActivated: {
plan: 'pro',
recurring: 'yearly',
},
subscriberCount: 1,
}
## should process expiration/refund by deleting subscription and emitting canceled
> should process expiration/refund and emit canceled
{
activatedCount: 0,
canceledCount: 1,
finalDBCount: 0,
lastCanceled: {
plan: 'pro',
recurring: 'yearly',
},
subscriberCount: 1,
}
## should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions
> should enqueue per-user RC reconciliation jobs (deduplicated by userId)
{
queued: [
{
name: 'nightly.revenuecat.syncUser',
opts: {
attempts: 3,
backoff: {
delay: 60000,
type: 'exponential',
},
jobId: 'nightly-rc-sync-u1',
},
payload: {
userId: 'u1',
},
},
{
name: 'nightly.revenuecat.syncUser',
opts: {
attempts: 3,
backoff: {
delay: 60000,
type: 'exponential',
},
jobId: 'nightly-rc-sync-u2',
},
payload: {
userId: 'u2',
},
},
],
uniqueJobCount: 2,
}
## should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
> should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
{
results: [
{
activatedCount: 1,
name: 'Pro monthly on iOS',
rec: {
iapStore: 'app_store',
plan: 'pro',
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-ios-1',
rcProductId: 'app.affine.pro.Monthly',
recurring: 'monthly',
status: 'active',
},
},
{
activatedCount: 1,
name: 'AI annual on Android',
rec: {
iapStore: 'play_store',
plan: 'ai',
provider: 'revenuecat',
rcEntitlement: 'AI',
rcExternalRef: 'token-android-1',
rcProductId: 'app.affine.pro.ai.Annual',
recurring: 'yearly',
status: 'active',
},
},
],
}
## should keep active and advance period dates when a trialing subscription renews
> should keep active after trial renewal
{
activatedCount: 2,
canceledCount: 0,
status: 'active',
}
## should remove or cancel the record and revoke entitlement when a trialing subscription expires
> should remove record
{
canceledCount: 1,
finalDBCount: 0,
}
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
> should keep active until period end when will_renew is false
{
activatedCount: 1,
canceledCount: 0,
hasCanceledAt: true,
status: 'active',
}
## should retain record as past_due (inactive but not expired) and NOT emit canceled event
> should retain past_due record and NOT emit canceled event
{
canceledCount: 0,
status: 'past_due',
}
## should skip RC upsert when Stripe active already exists for same plan
> should skip RC upsert when Stripe active already exists
{
activatedCount: 0,
hasRCRecord: false,
}
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
> should reconcile and fix missing or out-of-order states for revenuecat records
{
activatedCount: 1,
canceledCount: 0,
subscriberCount: 1,
}
## should treat refund as early expiration and revoke immediately
> should delete record and emit canceled on refund
{
canceledCount: 1,
finalDBCount: 0,
}
## should ignore non-whitelisted productId and not write to DB
> should ignore non-whitelisted productId and not write to DB
{
activatedCount: 0,
canceledCount: 0,
dbCount: 0,
}
## should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)
> should map via entitlement+duration fallback and ignore unsupported durations
{
aiViaFallback: {
plan: 'ai',
provider: 'revenuecat',
recurring: 'yearly',
},
eventsCounts: {
afterFirst: {
a: 1,
c: 0,
},
afterSecond: {
a: 2,
c: 0,
},
afterThird: {
a: 2,
c: 0,
},
},
proViaFallback: {
plan: 'pro',
provider: 'revenuecat',
recurring: 'monthly',
},
totalCount: 2,
}

View File

@@ -0,0 +1,912 @@
import { PrismaClient, User } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { omit } from 'lodash-es';
import Sinon from 'sinon';
import {
EventBus,
ManagedByAppStoreOrPlay,
SubscriptionAlreadyExists,
} from '../../base';
import { ConfigModule } from '../../base/config';
import { FeatureService } from '../../core/features';
import { Models } from '../../models';
import { PaymentModule } from '../../plugins/payment';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { UserSubscriptionManager } from '../../plugins/payment/manager';
import {
RcEvent,
resolveProductMapping,
RevenueCatService,
RevenueCatWebhookController,
RevenueCatWebhookHandler,
type Subscription,
} from '../../plugins/payment/revenuecat';
import { SubscriptionService } from '../../plugins/payment/service';
import {
SubscriptionPlan,
SubscriptionRecurring,
} from '../../plugins/payment/types';
import { createTestingApp, TestingApp } from '../utils';
type Ctx = {
module: TestingApp;
db: PrismaClient;
models: Models;
event: Sinon.SinonStubbedInstance<EventBus>;
service: SubscriptionService;
rc: RevenueCatService;
webhook: RevenueCatWebhookHandler;
controller: RevenueCatWebhookController;
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
triggerWebhook: (
userId: string,
event: Omit<RcEvent, 'app_id' | 'environment'>
) => Promise<void>;
collectEvents: () => {
activatedCount: number;
canceledCount: number;
events: Record<string, any[]>;
};
};
const test = ava as TestFn<Ctx>;
let user: User;
test.beforeEach(async t => {
const app = await createTestingApp({
imports: [
ConfigModule.override({
payment: {
revenuecat: {
enabled: true,
webhookAuth: '42',
},
},
}),
PaymentModule,
],
tapModule: m => {
m.overrideProvider(FeatureService).useValue(
Sinon.createStubInstance(FeatureService)
);
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
},
});
const db = app.get(PrismaClient);
const models = app.get(Models);
const event = app.get(EventBus) as Sinon.SinonStubbedInstance<EventBus>;
const service = app.get(SubscriptionService);
const rc = app.get(RevenueCatService);
const webhook = app.get(RevenueCatWebhookHandler);
const controller = app.get(RevenueCatWebhookController);
t.context.module = app;
t.context.db = db;
t.context.models = models;
t.context.event = event;
t.context.service = service;
t.context.rc = rc;
t.context.webhook = webhook;
t.context.controller = controller;
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
t.context.mockSubSeq = sequences => {
const stub = Sinon.stub(rc, 'getSubscriptions');
sequences.forEach((seq, idx) => {
if (idx === 0) stub.onFirstCall().resolves(seq);
else if (idx === 1) stub.onSecondCall().resolves(seq);
else stub.onCall(idx).resolves(seq);
});
return stub;
};
t.context.triggerWebhook = async (appUserId, event) => {
await webhook.onWebhook({
appUserId,
event: {
...event,
app_id: 'app.affine.pro',
environment: 'SANDBOX',
} as RcEvent,
});
};
t.context.collectEvents = () => {
const events = event.emit.getCalls().reduce(
(acc, c) => {
const [key, value] = c.args;
acc[key] = acc[key] || [];
acc[key].push(value);
return acc;
},
{} as { [key: string]: any[] }
);
const activatedCount = events['user.subscription.activated']?.length || 0;
const canceledCount = events['user.subscription.canceled']?.length || 0;
return { activatedCount, canceledCount, events };
};
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
user = await t.context.models.user.create({
email: 'test@affine.pro',
});
});
test.afterEach.always(async t => {
Sinon.reset();
await t.context.module.close();
});
test('should resolve product mapping consistently (whitelist, override, unknown)', t => {
const override = {
'custom.sku.monthly': { plan: 'pro', recurring: 'monthly' },
} as Record<string, { plan: string; recurring: string }>;
const actual = {
whitelist: {
proMonthly: resolveProductMapping({
productId: 'app.affine.pro.Monthly',
}),
proAnnual: resolveProductMapping({ productId: 'app.affine.pro.Annual' }),
aiAnnual: resolveProductMapping({
productId: 'app.affine.pro.ai.Annual',
}),
},
override: {
customMonthly: resolveProductMapping(
{ productId: 'custom.sku.monthly' },
override
),
},
unknown: resolveProductMapping({ productId: 'unknown.sku' }),
};
t.snapshot(actual, 'should map product for whitelist/override/unknown');
});
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
const { webhook, collectEvents, mockSub } = t.context;
const subscriber = mockSub([
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
expirationDate: new Date('2026-01-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: true,
duration: null,
},
]);
await webhook.onWebhook({
appUserId: user.id,
event: {
id: 'evt_1',
environment: 'PRODUCTION',
app_id: 'app.affine.pro',
type: 'INITIAL_PURCHASE',
store: 'app_store',
original_transaction_id: 'orig-tx-1',
},
});
const { activatedCount, canceledCount, events } = collectEvents();
const record = await t.context.db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: {
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
},
});
t.snapshot(
{
subscriberCount: subscriber.getCalls()?.length || 0,
activatedCount,
canceledCount,
lastActivated: omit(
events['user.subscription.activated']?.slice(-1)?.[0],
'userId'
),
dbObservability: record,
},
'should standardize payload and have events'
);
});
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'annual',
start: new Date('2025-01-01T00:00:00.000Z'),
},
});
const subscriber = mockSub([
{
identifier: 'Pro',
isActive: false,
latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'),
expirationDate: new Date('2024-02-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: false,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_2',
type: 'EXPIRATION',
store: 'app_store',
original_transaction_id: 'orig-tx-2',
});
const finalDBCount = await db.subscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { activatedCount, canceledCount, events } = collectEvents();
t.snapshot(
{
finalDBCount,
subscriberCount: subscriber.getCalls()?.length || 0,
activatedCount,
canceledCount,
lastCanceled: omit(
events['user.subscription.canceled']?.slice(-1)?.[0],
'userId'
),
},
'should process expiration/refund and emit canceled'
);
});
test('should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions', async t => {
const { module, db } = t.context;
const cron = module.get(SubscriptionCronJobs);
const common = { provider: 'revenuecat', start: new Date() } as const;
await db.subscription.createMany({
data: [
{
targetId: 'u1',
plan: 'pro',
status: 'active',
recurring: 'monthly',
...common,
},
{
targetId: 'u2',
plan: 'ai',
status: 'trialing',
recurring: 'annual',
...common,
},
{
targetId: 'u1',
plan: 'ai',
status: 'past_due',
recurring: 'monthly',
...common,
},
],
});
await cron.reconcileRevenueCatSubscriptions();
const calls = module.queue.add.getCalls().map(c => ({
name: c.args[0],
payload: c.args[1],
opts: c.args[2],
}));
t.snapshot(
{
queued: calls,
uniqueJobCount: calls.filter(
c => c.name === 'nightly.revenuecat.syncUser'
).length,
},
'should enqueue per-user RC reconciliation jobs (deduplicated by userId)'
);
});
test('should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)', async t => {
const { db, event, collectEvents, mockSubSeq, triggerWebhook } = t.context;
const scenarios = [
{
name: 'Pro monthly on iOS',
stub: [
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'),
expirationDate: new Date('2025-02-10T00:00:00.000Z'),
productId: 'app.affine.pro.Monthly',
store: 'app_store',
willRenew: true,
duration: null,
},
],
event: {
id: 'evt_ios_1',
type: 'INITIAL_PURCHASE',
store: 'app_store',
original_transaction_id: 'orig-ios-1',
},
expectedPlan: 'pro' as const,
},
{
name: 'AI annual on Android',
stub: [
{
identifier: 'AI',
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.ai.Annual',
store: 'play_store',
willRenew: true,
duration: null,
},
],
event: {
id: 'evt_android_1',
type: 'INITIAL_PURCHASE',
store: 'play_store',
purchase_token: 'token-android-1',
},
expectedPlan: 'ai' as const,
},
];
const results: any[] = [];
mockSubSeq(scenarios.map(s => s.stub));
for (const s of scenarios) {
// reset event history between scenarios for clean counts
event.emit.resetHistory?.();
await triggerWebhook(user.id, s.event);
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
select: {
plan: true,
recurring: true,
status: true,
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
},
});
const { activatedCount } = collectEvents();
results.push({ name: s.name, rec, activatedCount });
}
t.snapshot(
{ results },
'should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)'
);
});
test('should keep active and advance period dates when a trialing subscription renews', async t => {
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
mockSubSeq([
[
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: true,
duration: null,
},
],
[
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'),
expirationDate: new Date('2026-04-08T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: true,
duration: null,
},
],
]);
await triggerWebhook(user.id, {
id: 'evt_trial',
type: 'INITIAL_PURCHASE',
period_type: 'trial',
store: 'app_store',
});
await triggerWebhook(user.id, {
id: 'evt_renew',
type: 'RENEWAL',
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { status: true, start: true, end: true },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
{ status: rec?.status, activatedCount, canceledCount },
'should keep active after trial renewal'
);
});
test('should remove or cancel the record and revoke entitlement when a trialing subscription expires', async t => {
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
mockSubSeq([
[
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: false,
duration: null,
},
],
[
{
identifier: 'Pro',
isActive: false,
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
expirationDate: new Date('2024-01-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: false,
duration: null,
},
],
]);
await triggerWebhook(user.id, {
id: 'evt_trial2',
type: 'INITIAL_PURCHASE',
period_type: 'trial',
store: 'app_store',
});
await triggerWebhook(user.id, {
id: 'evt_expire_trial',
type: 'EXPIRATION',
store: 'app_store',
});
const finalDBCount = await db.subscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
t.snapshot({ finalDBCount, canceledCount }, 'should remove record');
});
test('should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
mockSub([
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
expirationDate: new Date('2025-06-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: false,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_cancel_before_end',
type: 'CANCELLATION',
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { status: true, canceledAt: true },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
{
status: rec?.status,
hasCanceledAt: !!rec?.canceledAt,
activatedCount,
canceledCount,
},
'should keep active until period end when will_renew is false'
);
});
test('should retain record as past_due (inactive but not expired) and NOT emit canceled event', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
mockSub([
{
identifier: 'Pro',
isActive: false,
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
expirationDate: new Date('2999-01-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'app_store',
willRenew: true,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_pastdue',
type: 'BILLING_ISSUE',
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { status: true },
});
const { canceledCount } = collectEvents();
t.snapshot(
{ status: rec?.status, canceledCount },
'should retain past_due record and NOT emit canceled event'
);
});
test('should block checkout when an existing subscription of the same plan is active', async t => {
const { module, db } = t.context;
const manager = module.get(UserSubscriptionManager);
{
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
},
});
await t.throwsAsync(
manager.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: null,
},
{
successCallbackLink: '/',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
},
{ user: { id: user.id, email: user.email } }
),
{ instanceOf: ManagedByAppStoreOrPlay }
);
}
{
await db.subscription.update({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
data: { provider: 'stripe' },
});
await t.throwsAsync(
() =>
manager.checkout(
{
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
variant: null,
},
{
successCallbackLink: '/',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
},
{ user: { id: user.id, email: user.email } }
),
{ instanceOf: SubscriptionAlreadyExists }
);
}
});
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'stripe',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
},
});
mockSub([
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'),
expirationDate: new Date('2025-07-01T00:00:00.000Z'),
productId: 'app.affine.pro.Monthly',
store: 'app_store',
willRenew: true,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_conflict',
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const rcRec = await db.subscription.findFirst({
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
});
const { activatedCount } = collectEvents();
t.snapshot(
{ hasRCRecord: !!rcRec, activatedCount },
'should skip RC upsert when Stripe active already exists'
);
});
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
const { db, service } = t.context;
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date(),
},
});
// local helper used multiple times within this test
const expectManaged = async (fn: () => Promise<any>) =>
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
await expectManaged(() =>
service.cancelSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
);
await expectManaged(() =>
service.resumeSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
);
await expectManaged(() =>
service.updateSubscriptionRecurring(
{ plan: SubscriptionPlan.Pro, userId: user.id },
SubscriptionRecurring.Yearly
)
);
});
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
const { webhook, collectEvents, mockSub } = t.context;
const subscriber = mockSub([
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'play_store',
willRenew: true,
duration: null,
},
]);
await webhook.syncAppUser(user.id);
const { activatedCount, canceledCount } = collectEvents();
const subscriberCount = subscriber.getCalls()?.length || 0;
t.snapshot(
{ subscriberCount, activatedCount, canceledCount },
'should reconcile and fix missing or out-of-order states for revenuecat records'
);
});
test('should treat refund as early expiration and revoke immediately', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
await db.subscription.create({
data: {
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
},
});
mockSub([
{
identifier: 'Pro',
isActive: false,
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
expirationDate: new Date('2025-01-15T00:00:00.000Z'),
productId: 'app.affine.pro.Monthly',
store: 'app_store',
willRenew: false,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_refund',
type: 'CANCELLATION',
store: 'app_store',
});
const count = await db.subscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
t.snapshot(
{ finalDBCount: count, canceledCount },
'should delete record and emit canceled on refund'
);
});
test('should ignore non-whitelisted productId and not write to DB', async t => {
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
mockSub([
{
identifier: 'Weird',
isActive: true,
latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'),
expirationDate: new Date('2026-07-01T00:00:00.000Z'),
productId: 'unknown.sku',
store: 'app_store',
willRenew: true,
duration: null,
},
]);
await triggerWebhook(user.id, {
id: 'evt_unknown',
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const dbCount = await db.subscription.count({ where: { targetId: user.id } });
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
{ dbCount, activatedCount, canceledCount },
'should ignore non-whitelisted productId and not write to DB'
);
});
test('should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)', async t => {
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
mockSubSeq([
[
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'),
expirationDate: new Date('2025-09-01T00:00:00.000Z'),
productId: 'unknown.sku',
store: 'app_store',
willRenew: true,
duration: 'P1M',
},
],
[
{
identifier: 'AI',
isActive: true,
latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'),
expirationDate: new Date('2026-10-01T00:00:00.000Z'),
productId: 'unknown.sku',
store: 'play_store',
willRenew: true,
duration: 'P1Y',
},
],
[
{
identifier: 'Pro',
isActive: true,
latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'),
expirationDate: new Date('2026-02-01T00:00:00.000Z'),
productId: 'unknown.sku',
store: 'app_store',
willRenew: true,
duration: 'P3M', // not supported -> ignore
},
],
]);
// pro monthly via fallback
await triggerWebhook(user.id, {
id: 'evt_fb1',
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const r1 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { plan: true, recurring: true, provider: true },
});
const s1 = collectEvents();
// ai yearly via fallback
await triggerWebhook(user.id, {
id: 'evt_fb2',
type: 'INITIAL_PURCHASE',
store: 'play_store',
});
const r2 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
select: { plan: true, recurring: true, provider: true },
});
const s2 = collectEvents();
// unsupported duration ignored
await triggerWebhook(user.id, {
id: 'evt_fb3',
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const count = await db.subscription.count({ where: { targetId: user.id } });
const s3 = collectEvents();
t.snapshot(
{
proViaFallback: r1,
aiViaFallback: r2,
totalCount: count,
eventsCounts: {
afterFirst: { a: s1.activatedCount, c: s1.canceledCount },
afterSecond: { a: s2.activatedCount, c: s2.canceledCount },
afterThird: { a: s3.activatedCount, c: s3.canceledCount },
},
},
'should map via entitlement+duration fallback and ignore unsupported durations'
);
});
test('should not dispatch webhook event when authorization header is missing or mismatched', async t => {
const { controller, event } = t.context;
const before = event.emitAsync.getCalls()?.length || 0;
const e = { id: '42', type: 'INITIAL_PURCHASE', app_user_id: user.id };
await controller.handleWebhook({ body: { event: e } } as any, undefined);
const after = event.emitAsync.getCalls()?.length || 0;
t.is(after - before, 0, 'should not emit event');
});

View File

@@ -192,8 +192,10 @@ test.before(async t => {
payment: {
enabled: true,
showLifetimePrice: true,
apiKey: '1',
webhookKey: '1',
stripe: {
apiKey: '1',
webhookKey: '1',
},
},
}),
AppModule,

View File

@@ -637,6 +637,11 @@ export const USER_FRIENDLY_ERRORS = {
type: 'invalid_input',
message: 'Workspace id is required to update team subscription.',
},
managed_by_app_store_or_play: {
type: 'action_forbidden',
message:
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
},
// Copilot errors
copilot_session_not_found: {

View File

@@ -651,6 +651,12 @@ export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyErr
}
}
export class ManagedByAppStoreOrPlay extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'managed_by_app_store_or_play', message);
}
}
export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'copilot_session_not_found', message);
@@ -1189,6 +1195,7 @@ export enum ErrorNames {
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
MANAGED_BY_APP_STORE_OR_PLAY,
COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_INVALID_INPUT,
COPILOT_SESSION_DELETED,

View File

@@ -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/',
},
});

View File

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

View File

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

View File

@@ -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 {}

View File

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

View File

@@ -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({

View File

@@ -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']),
},
});
}

View File

@@ -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'),
},
});
}

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
export { type RcEvent, RevenueCatWebhookController } from './controller';
export { resolveProductMapping } from './map';
export { RevenueCatService, type Subscription } from './service';
export { RevenueCatWebhookHandler } from './webhook';

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -744,6 +744,7 @@ enum ErrorNames {
LICENSE_REVEALED
LINK_EXPIRED
MAILER_SERVICE_IS_NOT_CONFIGURED
MANAGED_BY_APP_STORE_OR_PLAY
MEMBER_NOT_FOUND_IN_SPACE
MEMBER_QUOTA_EXCEEDED
MENTION_USER_DOC_ACCESS_DENIED
@@ -1962,6 +1963,11 @@ type SubscriptionType {
canceledAt: DateTime
createdAt: DateTime!
end: DateTime
"""
If provider is revenuecat, indicates underlying store. Read-only. One of: app_store | play_store
"""
iapStore: String
id: String @deprecated(reason: "removed")
nextBillAt: DateTime
@@ -1970,6 +1976,11 @@ type SubscriptionType {
There won't actually be a subscription with plan 'Free'
"""
plan: SubscriptionPlan!
"""
Payment provider of this subscription. Read-only. One of: stripe | revenuecat
"""
provider: String
recurring: SubscriptionRecurring!
start: DateTime!
status: SubscriptionStatus!