From a9937e18b6a13ca30a3dde838c2c837e93ecb3d4 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:08:57 +0800 Subject: [PATCH] fix: cleanup expired records (#14140) --- .../__snapshots__/revenuecat.spec.ts.md | 14 +-- .../__snapshots__/revenuecat.spec.ts.snap | Bin 2471 -> 2506 bytes .../src/__tests__/payment/revenuecat.spec.ts | 91 +++++++++--------- .../src/plugins/payment/revenuecat/webhook.ts | 51 +++++++++- 4 files changed, 101 insertions(+), 55 deletions(-) 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 b031908d700549a2dae85b56932550f80cbcfcfd..a724cdd4302c516c2c9727225867b372facf02a9 100644 GIT binary patch literal 2506 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 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; }