mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
fix: cleanup expired records (#14140)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user