mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-22 23:30:36 +08:00
feat: improve subscription sync
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user