feat(server): improve subscription sync stability (#14703)

This commit is contained in:
DarkSky
2026-03-23 21:13:00 +08:00
committed by GitHub
parent dcf041a3f2
commit 5d124ee55b
31 changed files with 1538 additions and 66 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient, Provider } from '@prisma/client';
@@ -18,6 +18,7 @@ declare global {
'nightly.cleanExpiredOnetimeSubscriptions': {};
'nightly.notifyAboutToExpireWorkspaceSubscriptions': {};
'nightly.reconcileRevenueCatSubscriptions': {};
'nightly.reconcileStripeSubscriptions': {};
'nightly.reconcileStripeRefunds': {};
'nightly.revenuecat.syncUser': { userId: string };
}
@@ -25,6 +26,8 @@ declare global {
@Injectable()
export class SubscriptionCronJobs {
private readonly logger = new Logger(SubscriptionCronJobs.name);
constructor(
private readonly db: PrismaClient,
private readonly event: EventBus,
@@ -61,6 +64,12 @@ export class SubscriptionCronJobs {
{ jobId: 'nightly-payment-reconcile-revenuecat-subscriptions' }
);
await this.queue.add(
'nightly.reconcileStripeSubscriptions',
{},
{ jobId: 'nightly-payment-reconcile-stripe-subscriptions' }
);
await this.queue.add(
'nightly.reconcileStripeRefunds',
{},
@@ -202,6 +211,48 @@ export class SubscriptionCronJobs {
await this.rcHandler.syncAppUser(payload.userId);
}
@OnJob('nightly.reconcileStripeSubscriptions')
async reconcileStripeSubscriptions() {
const stripe = this.stripeFactory.stripe;
const subs = await this.db.subscription.findMany({
where: {
provider: Provider.stripe,
stripeSubscriptionId: { not: null },
status: {
in: [
SubscriptionStatus.Active,
SubscriptionStatus.Trialing,
SubscriptionStatus.PastDue,
],
},
},
select: { stripeSubscriptionId: true },
});
const subscriptionIds = Array.from(
new Set(
subs
.map(sub => sub.stripeSubscriptionId)
.filter((id): id is string => !!id)
)
);
for (const subscriptionId of subscriptionIds) {
try {
const subscription = await stripe.subscriptions.retrieve(
subscriptionId,
{ expand: ['customer'] }
);
await this.subscription.saveStripeSubscription(subscription);
} catch (e) {
this.logger.error(
`Failed to reconcile stripe subscription ${subscriptionId}`,
e
);
}
}
}
@OnJob('nightly.reconcileStripeRefunds')
async reconcileStripeRefunds() {
const stripe = this.stripeFactory.stripe;
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { EventBus, OnEvent } from '../../base';
import { WorkspacePolicyService } from '../../core/permission';
import { WorkspaceService } from '../../core/workspaces';
import { Models } from '../../models';
import { SubscriptionPlan, SubscriptionRecurring } from './types';
@@ -10,7 +11,8 @@ export class PaymentEventHandlers {
constructor(
private readonly workspace: WorkspaceService,
private readonly models: Models,
private readonly event: EventBus
private readonly event: EventBus,
private readonly policy: WorkspacePolicyService
) {}
@OnEvent('workspace.subscription.activated')
@@ -40,6 +42,7 @@ export class PaymentEventHandlers {
// we only send emails when the team workspace is activated
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
}
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
break;
}
default:
@@ -54,7 +57,7 @@ export class PaymentEventHandlers {
}: Events['workspace.subscription.canceled']) {
switch (plan) {
case SubscriptionPlan.Team:
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
await this.policy.handleTeamPlanCanceled(workspaceId);
break;
default:
break;
@@ -81,6 +84,7 @@ export class PaymentEventHandlers {
recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1',
'subscription activated'
);
await this.policy.reconcileOwnedWorkspaces(userId);
break;
default:
break;
@@ -105,6 +109,7 @@ export class PaymentEventHandlers {
'free_plan_v1',
'lifetime subscription canceled'
);
await this.policy.reconcileOwnedWorkspaces(userId);
break;
}
@@ -121,6 +126,7 @@ export class PaymentEventHandlers {
'free_plan_v1',
'subscription canceled'
);
await this.policy.reconcileOwnedWorkspaces(userId);
}
break;
}