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

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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:
DarkSky
2026-05-19 22:48:05 +08:00
committed by GitHub
parent 103ad2a810
commit c53457691d
150 changed files with 13463 additions and 2458 deletions
@@ -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,