feat: improve subscription sync

This commit is contained in:
DarkSky
2026-03-19 20:23:26 +08:00
parent 6a93566422
commit adf8955e3f
8 changed files with 203 additions and 1 deletions

View File

@@ -81,6 +81,60 @@ e2e('should invite a user', async t => {
t.is(getInviteInfo2.status, WorkspaceMemberStatus.Accepted);
});
e2e('should re-check seat when accepting an email invitation', async t => {
const { owner, workspace } = await createWorkspace();
const member = await app.create(Mockers.User);
await app.create(Mockers.TeamWorkspace, {
id: workspace.id,
quantity: 4,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await app.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: (await app.create(Mockers.User)).id,
});
await app.login(owner);
const invite = await app.gql({
query: inviteByEmailsMutation,
variables: {
emails: [member.email],
workspaceId: workspace.id,
},
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
workspaceId: workspace.id,
quantity: 4,
});
await app.models.workspaceFeature.remove(workspace.id, 'team_plan_v1');
await app.login(member);
await t.throwsAsync(
app.gql({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: workspace.id,
inviteId: invite.inviteMembers[0].inviteId!,
},
})
);
const { getInviteInfo } = await app.gql({
query: getInviteInfoQuery,
variables: {
inviteId: invite.inviteMembers[0].inviteId!,
},
});
t.is(getInviteInfo.status, WorkspaceMemberStatus.Pending);
});
e2e('should leave a workspace', async t => {
const { owner, workspace } = await createWorkspace();
const u2 = await app.create(Mockers.User);

View File

@@ -5,6 +5,10 @@ import {
} from '@affine/graphql';
import { WorkspaceRole } from '../../../models';
import {
SubscriptionPlan,
SubscriptionRecurring,
} from '../../../plugins/payment/types';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
@@ -165,3 +169,53 @@ e2e('should set all rests to NeedMoreSeat', async t => {
WorkspaceMemberStatus.NeedMoreSeat
);
});
e2e(
'should cleanup non-accepted members when team workspace is downgraded',
async t => {
const { workspace } = await createTeamWorkspace();
const pending = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: pending.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
});
const allocating = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: allocating.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const underReview = await app.create(Mockers.User);
await app.create(Mockers.WorkspaceUser, {
userId: underReview.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.UnderReview,
});
await app.eventBus.emitAsync('workspace.subscription.canceled', {
workspaceId: workspace.id,
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
});
const [members] = await app.models.workspaceUser.paginate(workspace.id, {
first: 20,
offset: 0,
});
t.deepEqual(
members.map(member => member.status),
[
WorkspaceMemberStatus.Accepted,
WorkspaceMemberStatus.Accepted,
WorkspaceMemberStatus.Accepted,
]
);
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
}
);

View File

@@ -11,6 +11,7 @@ import { ConfigFactory, ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EarlyAccessType, FeatureService } from '../../core/features';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { SubscriptionService } from '../../plugins/payment/service';
import { StripeFactory } from '../../plugins/payment/stripe';
import {
@@ -871,6 +872,34 @@ test('should be able to cancel subscription', async t => {
t.truthy(subInDB.canceledAt);
});
test('should reconcile canceled stripe subscriptions and revoke local entitlement', async t => {
const { app, db, event, service, stripe, u1 } = t.context;
const cron = app.get(SubscriptionCronJobs);
await service.saveStripeSubscription(sub);
event.emit.resetHistory();
stripe.subscriptions.retrieve.resolves({
...sub,
status: SubscriptionStatus.Canceled,
} as any);
await cron.reconcileStripeSubscriptions();
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id, stripeSubscriptionId: sub.id },
});
t.is(subInDB, null);
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
})
);
});
test('should be able to resume subscription', async t => {
const { service, db, u1, stripe } = t.context;

View File

@@ -585,6 +585,12 @@ export class WorkspaceMemberResolver {
}
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
const hasSeat = await this.quota.tryCheckSeat(role.workspaceId, true);
if (!hasSeat) {
throw new NoMoreSeat({ spaceId: role.workspaceId });
}
await this.models.workspaceUser.setStatus(
role.workspaceId,
role.userId,

View File

@@ -201,6 +201,12 @@ export class WorkspaceUserModel extends BaseModel {
});
}
async deleteNonAccepted(workspaceId: string) {
return await this.db.workspaceUserRole.deleteMany({
where: { workspaceId, status: { not: WorkspaceMemberStatus.Accepted } },
});
}
async get(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {

View File

@@ -96,6 +96,7 @@ export class LicenseService {
}: Events['workspace.subscription.canceled']) {
switch (plan) {
case SubscriptionPlan.SelfHostedTeam:
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
break;
default:

View File

@@ -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;

View File

@@ -54,6 +54,7 @@ export class PaymentEventHandlers {
}: Events['workspace.subscription.canceled']) {
switch (plan) {
case SubscriptionPlan.Team:
await this.models.workspaceUser.deleteNonAccepted(workspaceId);
await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1');
break;
default: