fix: cleanup expired records (#14140)

This commit is contained in:
DarkSky
2025-12-23 22:08:57 +08:00
committed by GitHub
parent 7539135c4d
commit a9937e18b6
4 changed files with 101 additions and 55 deletions

View File

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

View File

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

View File

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