diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 8720d6287..4e3405b82 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -1108,18 +1108,33 @@ }, "apiKey": { "type": "string", - "description": "Stripe API key to enable payment service.\n@default \"\"\n@environment `STRIPE_API_KEY`", + "description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`", "default": "" }, "webhookKey": { "type": "string", - "description": "Stripe webhook key to enable payment service.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`", + "description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`", "default": "" }, "stripe": { "type": "object", - "description": "Stripe sdk options\n@default {}\n@link https://docs.stripe.com/api", - "default": {} + "description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api", + "default": { + "apiKey": "", + "webhookKey": "" + } + }, + "revenuecat": { + "type": "object", + "description": "RevenueCat integration configs\n@default {\"enabled\":false,\"apiKey\":\"\",\"projectId\":\"\",\"webhookAuth\":\"\",\"environment\":\"production\",\"productMap\":{}}\n@link https://www.revenuecat.com/docs/", + "default": { + "enabled": false, + "apiKey": "", + "projectId": "", + "webhookAuth": "", + "environment": "production", + "productMap": {} + } } } }, diff --git a/packages/backend/server/migrations/20250917092818_payment_rc_fields/migration.sql b/packages/backend/server/migrations/20250917092818_payment_rc_fields/migration.sql new file mode 100644 index 000000000..da56c1139 --- /dev/null +++ b/packages/backend/server/migrations/20250917092818_payment_rc_fields/migration.sql @@ -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; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 4561eafdd..d33304f48 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -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 diff --git a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md new file mode 100644 index 000000000..b46249297 --- /dev/null +++ b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md @@ -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, + } diff --git a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap new file mode 100644 index 000000000..b031908d7 Binary files /dev/null and b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts new file mode 100644 index 000000000..35fc095cb --- /dev/null +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -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; + + service: SubscriptionService; + rc: RevenueCatService; + webhook: RevenueCatWebhookHandler; + controller: RevenueCatWebhookController; + + mockSub: (subs: Subscription[]) => Sinon.SinonStub; + mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub; + triggerWebhook: ( + userId: string, + event: Omit + ) => Promise; + collectEvents: () => { + activatedCount: number; + canceledCount: number; + events: Record; + }; +}; + +const test = ava as TestFn; +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; + + 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; + + 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) => + 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'); +}); diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index 4c0da9c17..e1852e494 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -192,8 +192,10 @@ test.before(async t => { payment: { enabled: true, showLifetimePrice: true, - apiKey: '1', - webhookKey: '1', + stripe: { + apiKey: '1', + webhookKey: '1', + }, }, }), AppModule, diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 5cbdfc6c2..05a2ec7c5 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -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: { diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index 0400572f1..7c6047d7f 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -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, diff --git a/packages/backend/server/src/plugins/payment/config.ts b/packages/backend/server/src/plugins/payment/config.ts index e234d4cfc..fdc8c70b0 100644 --- a/packages/backend/server/src/plugins/payment/config.ts +++ b/packages/backend/server/src/plugins/payment/config.ts @@ -9,6 +9,13 @@ export interface PaymentStartupConfig { webhookKey: string; }; } & Stripe.StripeConfig; + revenuecat?: { + apiKey?: string; + webhookAuth?: string; + enabled?: boolean; + environment?: 'sandbox' | 'production'; + productMap?: Record; + }; } 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; + }>; }; } } @@ -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/', + }, }); diff --git a/packages/backend/server/src/plugins/payment/controller.ts b/packages/backend/server/src/plugins/payment/controller.ts index 03074d39f..605502b0d 100644 --- a/packages/backend/server/src/plugins/payment/controller.ts +++ b/packages/backend/server/src/plugins/payment/controller.ts @@ -19,7 +19,9 @@ export class StripeWebhookController { @Public() @Post('/webhook') async handleWebhook(@Req() req: RawBodyRequest) { - 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 { diff --git a/packages/backend/server/src/plugins/payment/cron.ts b/packages/backend/server/src/plugins/payment/cron.ts index d612a05af..eb2d5d1ce 100644 --- a/packages/backend/server/src/plugins/payment/cron.ts +++ b/packages/backend/server/src/plugins/payment/cron.ts @@ -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); + } } diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index f0b98a30d..bca6936c3 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -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 {} diff --git a/packages/backend/server/src/plugins/payment/manager/common.ts b/packages/backend/server/src/plugins/payment/manager/common.ts index a10d963f7..c5a5324e7 100644 --- a/packages/backend/server/src/plugins/payment/manager/common.ts +++ b/packages/backend/server/src/plugins/payment/manager/common.ts @@ -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 { diff --git a/packages/backend/server/src/plugins/payment/manager/selfhost.ts b/packages/backend/server/src/plugins/payment/manager/selfhost.ts index aa14be599..53fad460a 100644 --- a/packages/backend/server/src/plugins/payment/manager/selfhost.ts +++ b/packages/backend/server/src/plugins/payment/manager/selfhost.ts @@ -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({ diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index bd5964193..83e167373 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -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']), }, }); } diff --git a/packages/backend/server/src/plugins/payment/manager/workspace.ts b/packages/backend/server/src/plugins/payment/manager/workspace.ts index 42602112c..8e2b3a4d7 100644 --- a/packages/backend/server/src/plugins/payment/manager/workspace.ts +++ b/packages/backend/server/src/plugins/payment/manager/workspace.ts @@ -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'), }, }); } diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 62a6b4831..43ede3fbe 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -108,6 +108,23 @@ export class SubscriptionType implements Partial { @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', diff --git a/packages/backend/server/src/plugins/payment/revenuecat/controller.ts b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts new file mode 100644 index 000000000..27246e67f --- /dev/null +++ b/packages/backend/server/src/plugins/payment/revenuecat/controller.ts @@ -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; +type RcPayload = z.infer; + +@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 }; + } +} diff --git a/packages/backend/server/src/plugins/payment/revenuecat/index.ts b/packages/backend/server/src/plugins/payment/revenuecat/index.ts new file mode 100644 index 000000000..11b003175 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/revenuecat/index.ts @@ -0,0 +1,4 @@ +export { type RcEvent, RevenueCatWebhookController } from './controller'; +export { resolveProductMapping } from './map'; +export { RevenueCatService, type Subscription } from './service'; +export { RevenueCatWebhookHandler } from './webhook'; diff --git a/packages/backend/server/src/plugins/payment/revenuecat/map.ts b/packages/backend/server/src/plugins/payment/revenuecat/map.ts new file mode 100644 index 000000000..e8c42a733 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/revenuecat/map.ts @@ -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 = { + '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, + override?: Record +): 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 + ); +} diff --git a/packages/backend/server/src/plugins/payment/revenuecat/service.ts b/packages/backend/server/src/plugins/payment/revenuecat/service.ts new file mode 100644 index 000000000..e3450eb29 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/revenuecat/service.ts @@ -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; + +@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 { + 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; + } +} diff --git a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts new file mode 100644 index 000000000..f98bcd67a --- /dev/null +++ b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts @@ -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 + >; + 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; + } +} diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index f08e07c7f..9d80cf283 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -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(); } diff --git a/packages/backend/server/src/plugins/payment/stripe.ts b/packages/backend/server/src/plugins/payment/stripe.ts index 68ccc8a34..59a811a73 100644 --- a/packages/backend/server/src/plugins/payment/stripe.ts +++ b/packages/backend/server/src/plugins/payment/stripe.ts @@ -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 { diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index ac0497f9e..6181e9a7f 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -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; + }; } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 15551a45b..6db883acc 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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! diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 541cb9fb6..325592dfe 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -923,6 +923,7 @@ export enum ErrorNames { LICENSE_REVEALED = 'LICENSE_REVEALED', LINK_EXPIRED = 'LINK_EXPIRED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', + MANAGED_BY_APP_STORE_OR_PLAY = 'MANAGED_BY_APP_STORE_OR_PLAY', MEMBER_NOT_FOUND_IN_SPACE = 'MEMBER_NOT_FOUND_IN_SPACE', MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', MENTION_USER_DOC_ACCESS_DENIED = 'MENTION_USER_DOC_ACCESS_DENIED', @@ -2583,6 +2584,8 @@ export interface SubscriptionType { canceledAt: Maybe; createdAt: Scalars['DateTime']['output']; end: Maybe; + /** If provider is revenuecat, indicates underlying store. Read-only. One of: app_store | play_store */ + iapStore: Maybe; /** @deprecated removed */ id: Maybe; nextBillAt: Maybe; @@ -2591,6 +2594,8 @@ export interface 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: Maybe; recurring: SubscriptionRecurring; start: Scalars['DateTime']['output']; status: SubscriptionStatus; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index 9c6e48398..09f4368a2 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -400,18 +400,23 @@ }, "apiKey": { "type": "String", - "desc": "Stripe API key to enable payment service.", + "desc": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.", "env": "STRIPE_API_KEY" }, "webhookKey": { "type": "String", - "desc": "Stripe webhook key to enable payment service.", + "desc": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.", "env": "STRIPE_WEBHOOK_KEY" }, "stripe": { "type": "Object", - "desc": "Stripe sdk options", + "desc": "Stripe sdk options and credentials", "link": "https://docs.stripe.com/api" + }, + "revenuecat": { + "type": "Object", + "desc": "RevenueCat integration configs", + "link": "https://www.revenuecat.com/docs/" } }, "worker": { diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 287f05d15..2c9c0765f 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -8723,6 +8723,10 @@ export function useAFFiNEI18N(): { * `Workspace id is required to update team subscription.` */ ["error.WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION"](): string; + /** + * `This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.` + */ + ["error.MANAGED_BY_APP_STORE_OR_PLAY"](): string; /** * `Copilot session not found.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index e9ad1034e..5fa6256f8 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2169,6 +2169,7 @@ "error.CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION": "You cannot update an onetime payment subscription.", "error.WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION": "A workspace is required to checkout for team subscription.", "error.WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION": "Workspace id is required to update team subscription.", + "error.MANAGED_BY_APP_STORE_OR_PLAY": "This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.", "error.COPILOT_SESSION_NOT_FOUND": "Copilot session not found.", "error.COPILOT_SESSION_INVALID_INPUT": "Copilot session input is invalid.", "error.COPILOT_SESSION_DELETED": "Copilot session has been deleted.",