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 index b46249297..ee3a35039 100644 --- a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md +++ b/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.md @@ -58,8 +58,8 @@ Generated by [AVA](https://avajs.dev). > should process expiration/refund and emit canceled { - activatedCount: 0, - canceledCount: 1, + activatedEventCount: 0, + canceledEventCount: 2, finalDBCount: 0, lastCanceled: { plan: 'pro', @@ -206,7 +206,7 @@ Generated by [AVA](https://avajs.dev). > should delete record and emit canceled on refund { - canceledCount: 1, + canceledEventCount: 2, finalDBCount: 0, } @@ -236,12 +236,12 @@ Generated by [AVA](https://avajs.dev). c: 0, }, afterSecond: { - a: 2, + a: 3, c: 0, }, afterThird: { - a: 2, - c: 0, + a: 4, + c: 1, }, }, proViaFallback: { @@ -249,5 +249,5 @@ Generated by [AVA](https://avajs.dev). provider: 'revenuecat', recurring: 'monthly', }, - totalCount: 2, + totalCount: 1, } 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 index b031908d7..a724cdd43 100644 Binary files a/packages/backend/server/src/__tests__/payment/__snapshots__/revenuecat.spec.ts.snap 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 index d659c3e7e..a19383a93 100644 --- a/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts +++ b/packages/backend/server/src/__tests__/payment/revenuecat.spec.ts @@ -252,7 +252,7 @@ test('should process expiration/refund by deleting subscription and emitting can plan: 'pro', status: 'active', provider: 'revenuecat', - recurring: 'annual', + recurring: 'yearly', start: new Date('2025-01-01T00:00:00.000Z'), }, }); @@ -287,8 +287,8 @@ test('should process expiration/refund by deleting subscription and emitting can { finalDBCount, subscriberCount: subscriber.getCalls()?.length || 0, - activatedCount, - canceledCount, + activatedEventCount: activatedCount, + canceledEventCount: canceledCount, lastCanceled: omit( events['user.subscription.canceled']?.slice(-1)?.[0], 'userId' @@ -317,7 +317,7 @@ test('should enqueue per-user reconciliation jobs for existing RC active/trialin targetId: 'u2', plan: 'ai', status: 'trialing', - recurring: 'annual', + recurring: 'yearly', ...common, }, { @@ -818,7 +818,7 @@ test('should treat refund as early expiration and revoke immediately', async t = }); const { canceledCount } = collectEvents(); t.snapshot( - { finalDBCount: count, canceledCount }, + { finalDBCount: count, canceledEventCount: canceledCount }, 'should delete record and emit canceled on refund' ); }); @@ -858,47 +858,41 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 t.context; mockAlias(user.id); - mockSubSeq([ - [ - { - identifier: 'Pro', - isTrial: false, - 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', - isTrial: false, - 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', - isTrial: false, - 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 - }, - ], - ]); + const Pro = { + identifier: 'Pro', + isTrial: false, + isActive: true, + latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'), + expirationDate: new Date('2025-09-01T00:00:00.000Z'), + productId: 'app.affine.pro.Monthly', + store: 'app_store', + willRenew: true, + duration: 'P1M', + } as const; + const AI = { + identifier: 'AI', + isTrial: false, + isActive: true, + latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'), + expirationDate: new Date('2026-10-01T00:00:00.000Z'), + productId: 'app.affine.pro.ai.Annual', + store: 'play_store', + willRenew: true, + duration: 'P1Y', + } as const; + const Unsupported = { + identifier: 'Pro', + isTrial: false, + isActive: true, + latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'), + expirationDate: new Date('2026-02-01T00:00:00.000Z'), + productId: 'app.affine.pro.Quarterly', + store: 'app_store', + willRenew: true, + duration: 'P3M', // not supported -> ignore + } as const; + + mockSubSeq([[Pro], [Pro, AI], [Pro, Unsupported]]); // pro monthly via fallback await triggerWebhook(user.id, { @@ -937,10 +931,15 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1 { proViaFallback: r1, aiViaFallback: r2, + // unsupported duration ignored, count remains 1 totalCount: count, eventsCounts: { + // active pro plan, add 1 active event afterFirst: { a: s1.activatedCount, c: s1.canceledCount }, + // active pro and ai plans, add 2 active events afterSecond: { a: s2.activatedCount, c: s2.canceledCount }, + // add 2 active events, add 1 canceled events + // cancel pro plans and ignore unsupported plan afterThird: { a: s3.activatedCount, c: s3.canceledCount }, }, }, diff --git a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts index 4c22e9315..17f7b9679 100644 --- a/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts +++ b/packages/backend/server/src/plugins/payment/revenuecat/webhook.ts @@ -11,9 +11,13 @@ import { OnJob, sleep, } from '../../../base'; -import { SubscriptionStatus } from '../types'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionStatus, +} from '../types'; import { RcEvent } from './controller'; -import { resolveProductMapping } from './map'; +import { ProductMapping, resolveProductMapping } from './map'; import { RevenueCatService, Subscription } from './service'; const REFRESH_INTERVAL = 5 * 1000; // 5 seconds @@ -108,7 +112,24 @@ export class RevenueCatWebhookHandler { externalRef?: string, overrideExpirationDate?: Date ): Promise { + const cond = { targetId: appUserId, provider: Provider.revenuecat }; + const toBeCleanup = await this.db.subscription.findMany({ + where: cond, + }); const productOverride = this.config.payment.revenuecat?.productMap; + const removeExists = (mapping: ProductMapping, sub: Subscription) => { + // Remove from cleanup list + const index = toBeCleanup.findIndex(s => { + return ( + s.targetId === appUserId && + s.rcProductId === sub.productId && + s.plan === mapping.plan + ); + }); + if (index >= 0) { + toBeCleanup.splice(index, 1); + } + }; let success = 0; for (const sub of subscriptions) { @@ -182,6 +203,7 @@ export class RevenueCatWebhookHandler { recurring: mapping.recurring, }); } + removeExists(mapping, sub); continue; } @@ -249,7 +271,32 @@ export class RevenueCatWebhookHandler { recurring: mapping.recurring, }); } + + removeExists(mapping, sub); } + + if (toBeCleanup.length) { + for (const sub of toBeCleanup) { + await this.db.subscription.deleteMany({ where: { id: sub.id } }); + this.event.emit('user.subscription.canceled', { + userId: appUserId, + plan: sub.plan as SubscriptionPlan, + recurring: sub.recurring as SubscriptionRecurring, + }); + } + this.logger.log( + `Cleanup ${toBeCleanup.length} subscriptions for ${appUserId}`, + { + appUserId, + subscriptions: toBeCleanup.map(s => ({ + plan: s.plan, + recurring: s.recurring, + end: s.end, + })), + } + ); + } + return success > 0; }