mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 00:56:26 +08:00
feat(server): entitlement based model (#14996)
#### PR Dependency Tree * **PR #14996** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin mutations to grant/revoke commercial entitlements. * New Doc comment-update permission. * Realtime user/workspace quota-state endpoints and live-update rooms. * **Bug Fixes** * More accurate readable-doc filtering and permission evaluation. * **Refactor** * Workspace feature management moved to entitlement-based model; permission and quota pipelines redesigned. * Admin workspace UI now edits flags only (feature toggles removed). * **Tests** * Extensive new and updated tests for permissions, entitlements, quota, projection, and backfills. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient, Provider } from '@prisma/client';
|
||||
|
||||
import { EventBus, JobQueue, OneHour, OnJob } from '../../base';
|
||||
import { EntitlementService } from '../../core/entitlement';
|
||||
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||
import { SubscriptionService } from './service';
|
||||
import { StripeFactory } from './stripe';
|
||||
@@ -34,7 +35,8 @@ export class SubscriptionCronJobs {
|
||||
private readonly queue: JobQueue,
|
||||
private readonly rcHandler: RevenueCatWebhookHandler,
|
||||
private readonly stripeFactory: StripeFactory,
|
||||
private readonly subscription: SubscriptionService
|
||||
private readonly subscription: SubscriptionService,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
private getDateRange(after: number, base: number | Date = Date.now()) {
|
||||
@@ -157,6 +159,12 @@ export class SubscriptionCronJobs {
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: subscription.targetId,
|
||||
plan: subscription.plan,
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
});
|
||||
await this.db.subscription.delete({
|
||||
where: {
|
||||
targetId_plan: {
|
||||
|
||||
@@ -2,47 +2,32 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { EventBus, OnEvent } from '../../base';
|
||||
import { WorkspacePolicyService } from '../../core/permission';
|
||||
import { QuotaStateService } from '../../core/quota/state';
|
||||
import { WorkspaceService } from '../../core/workspaces';
|
||||
import { Models } from '../../models';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from './types';
|
||||
import { SubscriptionPlan } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentEventHandlers {
|
||||
constructor(
|
||||
private readonly workspace: WorkspaceService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus,
|
||||
private readonly policy: WorkspacePolicyService
|
||||
private readonly policy: WorkspacePolicyService,
|
||||
private readonly quotaState: QuotaStateService,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@OnEvent('workspace.subscription.activated')
|
||||
async onWorkspaceSubscriptionUpdated({
|
||||
workspaceId,
|
||||
plan,
|
||||
recurring,
|
||||
quantity,
|
||||
}: Events['workspace.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case 'team': {
|
||||
const isTeam = await this.workspace.isTeamWorkspace(workspaceId);
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
'team_plan_v1',
|
||||
`${recurring} team subscription activated`,
|
||||
{
|
||||
memberLimit: quantity,
|
||||
}
|
||||
);
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId,
|
||||
quantity,
|
||||
});
|
||||
if (!isTeam) {
|
||||
// this event will triggered when subscription is activated or changed
|
||||
// we only send emails when the team workspace is activated
|
||||
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
|
||||
}
|
||||
await this.policy.reconcileWorkspaceQuotaState(workspaceId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -68,22 +53,10 @@ export class PaymentEventHandlers {
|
||||
async onUserSubscriptionUpdated({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: Events['user.subscription.activated']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.models.userFeature.add(
|
||||
userId,
|
||||
'unlimited_copilot',
|
||||
'subscription activated'
|
||||
);
|
||||
break;
|
||||
case SubscriptionPlan.Pro:
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1',
|
||||
'subscription activated'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
default:
|
||||
@@ -95,43 +68,35 @@ export class PaymentEventHandlers {
|
||||
async onUserSubscriptionCanceled({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: Events['user.subscription.canceled']) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
await this.models.userFeature.remove(userId, 'unlimited_copilot');
|
||||
break;
|
||||
case SubscriptionPlan.Pro: {
|
||||
// if user disputed a lifetime plan, we just switch them to free plan directly
|
||||
if (recurring === SubscriptionRecurring.Lifetime) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'lifetime subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
}
|
||||
|
||||
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
|
||||
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
|
||||
const isLifetimeUser = await this.models.userFeature.has(
|
||||
userId,
|
||||
'lifetime_pro_plan_v1'
|
||||
);
|
||||
|
||||
if (!isLifetimeUser) {
|
||||
await this.models.userFeature.switchQuota(
|
||||
userId,
|
||||
'free_plan_v1',
|
||||
'subscription canceled'
|
||||
);
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
}
|
||||
await this.policy.reconcileOwnedWorkspaces(userId);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('entitlement.changed')
|
||||
async onEntitlementChanged({
|
||||
targetType,
|
||||
targetId,
|
||||
}: Events['entitlement.changed']) {
|
||||
if (targetType !== 'workspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await this.quotaState.reconcileWorkspaceQuotaState(targetId);
|
||||
if (state.plan !== 'team' && state.plan !== 'selfhost_team') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.event.emit('workspace.members.allocateSeats', {
|
||||
workspaceId: targetId,
|
||||
quantity: state.seatLimit ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import './config';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServerConfigModule } from '../../core';
|
||||
import { EntitlementModule } from '../../core/entitlement';
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { MailModule } from '../../core/mail';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
@@ -41,6 +42,7 @@ import { StripeWebhook } from './webhook';
|
||||
WorkspaceModule,
|
||||
MailModule,
|
||||
ServerConfigModule,
|
||||
EntitlementModule,
|
||||
],
|
||||
providers: [
|
||||
StripeFactory,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TooManyRequest,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { EarlyAccessType, FeatureService } from '../../../core/features';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
@@ -64,7 +65,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
private readonly feature: FeatureService,
|
||||
private readonly event: EventBus,
|
||||
private readonly url: URLHelper,
|
||||
private readonly mutex: Mutex
|
||||
private readonly mutex: Mutex,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -254,7 +256,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
const subscriptionData = this.transformSubscription(subscription);
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
},
|
||||
@@ -270,6 +272,8 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
@@ -285,6 +289,11 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId,
|
||||
plan: lookupKey.plan,
|
||||
@@ -416,7 +425,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
|
||||
if (prevSubscription) {
|
||||
if (prevSubscription.stripeSubscriptionId) {
|
||||
await this.db.subscription.update({
|
||||
const subscription = await this.db.subscription.update({
|
||||
where: {
|
||||
id: prevSubscription.id,
|
||||
},
|
||||
@@ -431,6 +440,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
|
||||
await this.stripe.subscriptions.cancel(
|
||||
prevSubscription.stripeSubscriptionId,
|
||||
@@ -440,7 +450,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.db.subscription.create({
|
||||
const subscription = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: knownInvoice.userId,
|
||||
stripeSubscriptionId: null,
|
||||
@@ -452,6 +462,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(subscription);
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
@@ -552,6 +563,10 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
plan: lookupKey.plan,
|
||||
recurring: lookupKey.recurring,
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription({
|
||||
...subscription,
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
@@ -582,6 +597,12 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
canceledAt: new Date(),
|
||||
},
|
||||
});
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: userId,
|
||||
plan: lookupKey.plan,
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
});
|
||||
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId,
|
||||
@@ -621,7 +642,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
await this.db.subscription.update({
|
||||
const saved = await this.db.subscription.update({
|
||||
where: { id: subscription.id },
|
||||
data: {
|
||||
status: SubscriptionStatus.Active,
|
||||
@@ -631,8 +652,9 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
end,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
} else {
|
||||
await this.db.subscription.create({
|
||||
const saved = await this.db.subscription.create({
|
||||
data: {
|
||||
targetId: userId,
|
||||
stripeSubscriptionId: null,
|
||||
@@ -643,6 +665,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
||||
nextBillAt: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
}
|
||||
|
||||
this.event.emit('user.subscription.activated', {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SubscriptionPlanNotFound,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { Models } from '../../../models';
|
||||
import { StripeFactory } from '../stripe';
|
||||
import {
|
||||
@@ -50,7 +51,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
db: PrismaClient,
|
||||
private readonly url: URLHelper,
|
||||
private readonly event: EventBus,
|
||||
private readonly models: Models
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {
|
||||
super(stripeProvider, db);
|
||||
}
|
||||
@@ -155,7 +157,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
}
|
||||
|
||||
return this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
provider: Provider.stripe,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
@@ -175,6 +177,8 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deleteStripeSubscription({
|
||||
@@ -194,6 +198,11 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
});
|
||||
this.event.emit('workspace.subscription.canceled', {
|
||||
workspaceId,
|
||||
plan: lookupKey.plan,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
WorkspaceIdRequiredToUpdateTeamSubscription,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { PermissionAccess } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
|
||||
@@ -665,7 +665,7 @@ export class WorkspaceSubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: WorkspaceSubscriptionManager,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly ac: AccessController
|
||||
private readonly ac: PermissionAccess
|
||||
) {}
|
||||
|
||||
@ResolveField(() => SubscriptionType, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
OnJob,
|
||||
sleep,
|
||||
} from '../../../base';
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
@@ -32,7 +33,8 @@ export class RevenueCatWebhookHandler {
|
||||
private readonly db: PrismaClient,
|
||||
private readonly config: Config,
|
||||
private readonly event: EventBus,
|
||||
private readonly queue: JobQueue
|
||||
private readonly queue: JobQueue,
|
||||
private readonly entitlement: EntitlementService
|
||||
) {}
|
||||
|
||||
@OnEvent('revenuecat.webhook')
|
||||
@@ -197,6 +199,10 @@ export class RevenueCatWebhookHandler {
|
||||
},
|
||||
});
|
||||
if (result.count > 0) {
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: appUserId,
|
||||
plan: mapping.plan,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: mapping.plan,
|
||||
@@ -207,7 +213,7 @@ export class RevenueCatWebhookHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.db.subscription.upsert({
|
||||
const saved = await this.db.subscription.upsert({
|
||||
where: {
|
||||
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||
},
|
||||
@@ -252,6 +258,7 @@ export class RevenueCatWebhookHandler {
|
||||
trialEnd: null,
|
||||
},
|
||||
});
|
||||
await this.entitlement.upsertFromCloudSubscription(saved);
|
||||
|
||||
if (
|
||||
status === SubscriptionStatus.Active ||
|
||||
@@ -278,6 +285,12 @@ export class RevenueCatWebhookHandler {
|
||||
if (toBeCleanup.length) {
|
||||
for (const sub of toBeCleanup) {
|
||||
await this.db.subscription.deleteMany({ where: { id: sub.id } });
|
||||
await this.entitlement.revokeCloudSubscription({
|
||||
targetId: appUserId,
|
||||
plan: sub.plan as SubscriptionPlan,
|
||||
subscriptionId: sub.id,
|
||||
stripeSubscriptionId: sub.stripeSubscriptionId,
|
||||
});
|
||||
this.event.emit('user.subscription.canceled', {
|
||||
userId: appUserId,
|
||||
plan: sub.plan as SubscriptionPlan,
|
||||
|
||||
Reference in New Issue
Block a user