Add entitlement runtime

This commit is contained in:
diyaa 2026-06-28 15:10:57 +02:00
parent 13e91cb764
commit aa7d85b2e0
3 changed files with 629 additions and 0 deletions

View File

@ -0,0 +1,405 @@
import { randomUUID } from 'node:crypto';
import { SubscriptionPlan, SubscriptionStatus } from '@prisma/client';
import {
ENTITLEMENT_KEYS,
ENTITLEMENT_SOURCES,
EntitlementService,
} from './entitlement.service';
describe('EntitlementService', () => {
const now = new Date('2026-06-28T12:00:00.000Z');
const future = new Date('2026-07-28T12:00:00.000Z');
const past = new Date('2026-06-27T12:00:00.000Z');
const buildService = () => {
const subscriptions = new Map<string, any>();
const entitlements = new Map<string, any>();
const entitlementId = (userId: string, entitlementKey: string) =>
`${userId}:${entitlementKey}`;
const userSubscription = {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
const subscription = subscriptions.get(where.userId);
if (!subscription) {
return null;
}
return {
plan: subscription.plan,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
};
}),
};
const userEntitlement = {
findUnique: jest.fn().mockImplementation(async ({ where }) => {
const key = where.userId_entitlementKey;
const entitlement = entitlements.get(
entitlementId(key.userId, key.entitlementKey),
);
return entitlement ? { granted: entitlement.granted } : null;
}),
findMany: jest.fn().mockImplementation(async ({ where }) =>
Array.from(entitlements.values())
.filter((entitlement) => entitlement.userId === where.userId)
.sort((left, right) =>
left.entitlementKey.localeCompare(right.entitlementKey),
)
.map(
({ entitlementKey, granted, source, createdAt, updatedAt }) => ({
entitlementKey,
granted,
source,
createdAt,
updatedAt,
}),
),
),
upsert: jest
.fn()
.mockImplementation(async ({ where, update, create }) => {
const key = where.userId_entitlementKey;
const id = entitlementId(key.userId, key.entitlementKey);
const existing = entitlements.get(id);
const timestamp = new Date(now);
const entitlement = existing
? { ...existing, ...update, updatedAt: timestamp }
: {
id: randomUUID(),
...create,
createdAt: timestamp,
updatedAt: timestamp,
};
entitlements.set(id, entitlement);
return {
entitlementKey: entitlement.entitlementKey,
granted: entitlement.granted,
source: entitlement.source,
createdAt: entitlement.createdAt,
updatedAt: entitlement.updatedAt,
};
}),
updateMany: jest.fn().mockImplementation(async ({ where, data }) => {
let count = 0;
for (const [id, entitlement] of entitlements) {
if (
entitlement.userId === where.userId &&
where.entitlementKey.in.includes(entitlement.entitlementKey) &&
entitlement.source === where.source
) {
entitlements.set(id, {
...entitlement,
...data,
updatedAt: new Date(now),
});
count += 1;
}
}
return { count };
}),
createMany: jest.fn().mockImplementation(async ({ data }) => {
let count = 0;
for (const create of data) {
const id = entitlementId(create.userId, create.entitlementKey);
if (entitlements.has(id)) {
continue;
}
entitlements.set(id, {
id: randomUUID(),
...create,
createdAt: new Date(now),
updatedAt: new Date(now),
});
count += 1;
}
return { count };
}),
};
const prismaService = {
userSubscription,
userEntitlement,
} as any;
return {
service: new EntitlementService(prismaService),
subscriptions,
entitlements,
prismaService,
setSubscription: (
userId: string,
status: SubscriptionStatus,
plan: SubscriptionPlan,
currentPeriodEnd = future,
) => {
subscriptions.set(userId, {
userId,
status,
plan,
currentPeriodEnd,
providerCustomerId: 'provider-id-must-not-affect-plan',
providerSubscriptionId: 'provider-subscription-id',
});
},
setEntitlement: (
userId: string,
entitlementKey: string,
granted: boolean,
source: string,
) => {
entitlements.set(entitlementId(userId, entitlementKey), {
id: randomUUID(),
userId,
entitlementKey,
granted,
source,
createdAt: new Date(now),
updatedAt: new Date(now),
});
},
};
};
it('returns FREE when the user has no subscription', async () => {
const { service } = buildService();
await expect(service.getEffectivePlan(randomUUID(), now)).resolves.toBe(
SubscriptionPlan.FREE,
);
});
it('returns PRO for an ACTIVE PRO subscription in its current period', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.ACTIVE,
SubscriptionPlan.PRO,
);
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
SubscriptionPlan.PRO,
);
});
it('returns PRO for a current TRIAL', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.TRIAL,
SubscriptionPlan.FREE,
);
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
SubscriptionPlan.PRO,
);
});
it('does not infer PRO from provider IDs on an ACTIVE FREE subscription', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.ACTIVE,
SubscriptionPlan.FREE,
);
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
SubscriptionPlan.FREE,
);
});
it('returns FREE for an ACTIVE PRO subscription past its period end', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.ACTIVE,
SubscriptionPlan.PRO,
past,
);
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
SubscriptionPlan.FREE,
);
});
it.each([
SubscriptionStatus.PAST_DUE,
SubscriptionStatus.CANCELLED,
SubscriptionStatus.EXPIRED,
])('returns FREE for a current %s subscription', async (status) => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(userId, status, SubscriptionPlan.PRO);
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
SubscriptionPlan.FREE,
);
});
it('allows a directly granted entitlement on the FREE plan', async () => {
const { service } = buildService();
const userId = randomUUID();
await service.grantEntitlement(
userId,
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
);
await expect(
service.hasEntitlement(
userId,
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
now,
),
).resolves.toBe(true);
});
it('allows Pro feature access from the effective plan before a sync', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.ACTIVE,
SubscriptionPlan.PRO,
);
await expect(
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRO_LIBRARY, now),
).resolves.toBe(true);
});
it('denies a revoked entitlement on the FREE plan', async () => {
const { service } = buildService();
const userId = randomUUID();
await service.revokeEntitlement(
userId,
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
);
await expect(
service.hasEntitlement(
userId,
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
now,
),
).resolves.toBe(false);
});
it('grants all Pro entitlements when syncing an effective PRO plan', async () => {
const { service, setSubscription } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.ACTIVE,
SubscriptionPlan.PRO,
);
const result = await service.syncEntitlementsForSubscription(userId, now);
expect(result).toHaveLength(EntitlementService.proEntitlementKeys.length);
expect(result).toEqual(
expect.arrayContaining(
EntitlementService.proEntitlementKeys.map((entitlementKey) =>
expect.objectContaining({
entitlementKey,
granted: true,
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
}),
),
),
);
});
it('revokes only subscription-sourced Pro entitlements when syncing FREE', async () => {
const { service, setEntitlement } = buildService();
const userId = randomUUID();
setEntitlement(
userId,
ENTITLEMENT_KEYS.PRO_LIBRARY,
true,
ENTITLEMENT_SOURCES.SUBSCRIPTION,
);
setEntitlement(
userId,
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
true,
ENTITLEMENT_SOURCES.MANUAL,
);
const result = await service.syncEntitlementsForSubscription(userId, now);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
entitlementKey: ENTITLEMENT_KEYS.PRO_LIBRARY,
granted: false,
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
}),
expect.objectContaining({
entitlementKey: ENTITLEMENT_KEYS.SMART_DOWNLOADS,
granted: true,
source: ENTITLEMENT_SOURCES.MANUAL,
}),
]),
);
});
it('does not create duplicate entitlements across repeated syncs', async () => {
const { service, setSubscription, entitlements } = buildService();
const userId = randomUUID();
setSubscription(
userId,
SubscriptionStatus.TRIAL,
SubscriptionPlan.PRO,
);
await service.syncEntitlementsForSubscription(userId, now);
await service.syncEntitlementsForSubscription(userId, now);
expect(
Array.from(entitlements.values()).filter(
(entitlement) => entitlement.userId === userId,
),
).toHaveLength(EntitlementService.proEntitlementKeys.length);
});
it('keeps entitlement reads, checks, and syncs scoped to the requested user', async () => {
const { service, setEntitlement } = buildService();
const userId = randomUUID();
const otherUserId = randomUUID();
setEntitlement(
otherUserId,
ENTITLEMENT_KEYS.PRIORITY_SYNC,
true,
ENTITLEMENT_SOURCES.MANUAL,
);
await expect(
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRIORITY_SYNC, now),
).resolves.toBe(false);
await expect(service.getUserEntitlements(userId)).resolves.toEqual([]);
await service.syncEntitlementsForSubscription(userId, now);
await expect(service.getUserEntitlements(otherUserId)).resolves.toEqual([
expect.objectContaining({
entitlementKey: ENTITLEMENT_KEYS.PRIORITY_SYNC,
granted: true,
}),
]);
});
});

View File

@ -0,0 +1,221 @@
import { Injectable } from '@nestjs/common';
import { SubscriptionPlan, SubscriptionStatus } from '@prisma/client';
import { PrismaService } from '../../infrastructure/database/prisma.service';
export const ENTITLEMENT_KEYS = {
PRO_LIBRARY: 'PRO_LIBRARY',
UNLIMITED_DEVICES: 'UNLIMITED_DEVICES',
ADVANCED_AUDIO_ANALYSIS: 'ADVANCED_AUDIO_ANALYSIS',
SMART_DOWNLOADS: 'SMART_DOWNLOADS',
PRIORITY_SYNC: 'PRIORITY_SYNC',
} as const;
export type EntitlementKey =
(typeof ENTITLEMENT_KEYS)[keyof typeof ENTITLEMENT_KEYS];
export const ENTITLEMENT_SOURCES = {
SUBSCRIPTION: 'SUBSCRIPTION',
MANUAL: 'MANUAL',
SYSTEM: 'SYSTEM',
} as const;
export type EntitlementSource =
(typeof ENTITLEMENT_SOURCES)[keyof typeof ENTITLEMENT_SOURCES];
export interface UserEntitlementSummary {
entitlementKey: string;
granted: boolean;
source: string;
createdAt: Date;
updatedAt: Date;
}
const PRO_ENTITLEMENT_KEYS = Object.values(ENTITLEMENT_KEYS);
@Injectable()
export class EntitlementService {
static readonly proEntitlementKeys: readonly EntitlementKey[] =
PRO_ENTITLEMENT_KEYS;
constructor(private readonly prismaService: PrismaService) {}
async getEffectivePlan(
userId: string,
now = new Date(),
): Promise<SubscriptionPlan> {
const subscription = await this.prismaService.userSubscription.findUnique({
where: {
userId,
},
select: {
plan: true,
status: true,
currentPeriodEnd: true,
},
});
if (!subscription || subscription.currentPeriodEnd <= now) {
return SubscriptionPlan.FREE;
}
if (subscription.status === SubscriptionStatus.TRIAL) {
return SubscriptionPlan.PRO;
}
if (
subscription.status === SubscriptionStatus.ACTIVE &&
subscription.plan === SubscriptionPlan.PRO
) {
return SubscriptionPlan.PRO;
}
return SubscriptionPlan.FREE;
}
async getUserEntitlements(
userId: string,
): Promise<UserEntitlementSummary[]> {
return this.prismaService.userEntitlement.findMany({
where: {
userId,
},
orderBy: {
entitlementKey: 'asc',
},
select: {
entitlementKey: true,
granted: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
}
async hasEntitlement(
userId: string,
entitlementKey: EntitlementKey,
now = new Date(),
): Promise<boolean> {
const [directEntitlement, effectivePlan] = await Promise.all([
this.prismaService.userEntitlement.findUnique({
where: {
userId_entitlementKey: {
userId,
entitlementKey,
},
},
select: {
granted: true,
},
}),
this.getEffectivePlan(userId, now),
]);
if (directEntitlement?.granted) {
return true;
}
return (
effectivePlan === SubscriptionPlan.PRO &&
PRO_ENTITLEMENT_KEYS.includes(entitlementKey)
);
}
async grantEntitlement(
userId: string,
entitlementKey: EntitlementKey,
source: EntitlementSource = ENTITLEMENT_SOURCES.MANUAL,
): Promise<UserEntitlementSummary> {
return this.setDirectEntitlement(userId, entitlementKey, true, source);
}
async revokeEntitlement(
userId: string,
entitlementKey: EntitlementKey,
source: EntitlementSource = ENTITLEMENT_SOURCES.MANUAL,
): Promise<UserEntitlementSummary> {
return this.setDirectEntitlement(userId, entitlementKey, false, source);
}
async syncEntitlementsForSubscription(
userId: string,
now = new Date(),
): Promise<UserEntitlementSummary[]> {
const effectivePlan = await this.getEffectivePlan(userId, now);
const subscriptionSource = ENTITLEMENT_SOURCES.SUBSCRIPTION;
if (effectivePlan === SubscriptionPlan.PRO) {
await this.prismaService.userEntitlement.updateMany({
where: {
userId,
entitlementKey: {
in: PRO_ENTITLEMENT_KEYS,
},
source: subscriptionSource,
},
data: {
granted: true,
},
});
await this.prismaService.userEntitlement.createMany({
data: PRO_ENTITLEMENT_KEYS.map((entitlementKey) => ({
userId,
entitlementKey,
granted: true,
source: subscriptionSource,
})),
skipDuplicates: true,
});
} else {
await this.prismaService.userEntitlement.updateMany({
where: {
userId,
entitlementKey: {
in: PRO_ENTITLEMENT_KEYS,
},
source: subscriptionSource,
},
data: {
granted: false,
},
});
}
return this.getUserEntitlements(userId);
}
private async setDirectEntitlement(
userId: string,
entitlementKey: EntitlementKey,
granted: boolean,
source: EntitlementSource,
): Promise<UserEntitlementSummary> {
return this.prismaService.userEntitlement.upsert({
where: {
userId_entitlementKey: {
userId,
entitlementKey,
},
},
update: {
granted,
source,
},
create: {
userId,
entitlementKey,
granted,
source,
},
select: {
entitlementKey: true,
granted: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
}
}

View File

@ -7,6 +7,7 @@ import { AccountService } from './account.service';
import { DefaultUserService } from './default-user.service';
import { DeviceManagementService } from './device-management.service';
import { DeviceLinkingService } from './device-linking.service';
import { EntitlementService } from './entitlement.service';
import { OAuthIdentityService } from './oauth-identity.service';
import { OwnershipTransferService } from './ownership-transfer.service';
import {
@ -23,6 +24,7 @@ import { StorageModule } from '../storage/storage.module';
DefaultUserService,
DeviceManagementService,
DeviceLinkingService,
EntitlementService,
OAuthIdentityService,
OwnershipTransferService,
BootstrapOwnerContextService,
@ -37,6 +39,7 @@ import { StorageModule } from '../storage/storage.module';
AccountService,
DeviceManagementService,
DeviceLinkingService,
EntitlementService,
OAuthIdentityService,
OwnershipTransferService,
],