From adf8955e3fc507dae7843d5a9b50eaac5d699208 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Thu, 19 Mar 2026 20:23:26 +0800 Subject: [PATCH] feat: improve subscription sync --- .../__tests__/e2e/workspace/member.spec.ts | 54 +++++++++++++++++++ .../src/__tests__/e2e/workspace/team.spec.ts | 54 +++++++++++++++++++ .../src/__tests__/payment/service.spec.ts | 29 ++++++++++ .../src/core/workspaces/resolvers/member.ts | 6 +++ .../server/src/models/workspace-user.ts | 6 +++ .../server/src/plugins/license/service.ts | 1 + .../server/src/plugins/payment/cron.ts | 53 +++++++++++++++++- .../server/src/plugins/payment/event.ts | 1 + 8 files changed, 203 insertions(+), 1 deletion(-) diff --git a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts index 1a93a56a4b..02314bd943 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/member.spec.ts @@ -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); diff --git a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts index 19541aed9d..8cf6945a97 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/team.spec.ts @@ -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)); + } +); diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index b96e1ed353..be873ba31a 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -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; diff --git a/packages/backend/server/src/core/workspaces/resolvers/member.ts b/packages/backend/server/src/core/workspaces/resolvers/member.ts index 2256089580..f6be467815 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/member.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/member.ts @@ -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, diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index d6e074f7b8..023e57e7b9 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -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: { diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index b8a9d2d559..f61bc3e045 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -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: diff --git a/packages/backend/server/src/plugins/payment/cron.ts b/packages/backend/server/src/plugins/payment/cron.ts index 49a822f076..cead2f1150 100644 --- a/packages/backend/server/src/plugins/payment/cron.ts +++ b/packages/backend/server/src/plugins/payment/cron.ts @@ -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; diff --git a/packages/backend/server/src/plugins/payment/event.ts b/packages/backend/server/src/plugins/payment/event.ts index a5607a30d7..348ec12c88 100644 --- a/packages/backend/server/src/plugins/payment/event.ts +++ b/packages/backend/server/src/plugins/payment/event.ts @@ -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: