diff --git a/backend/src/modules/users/entitlement.service.spec.ts b/backend/src/modules/users/entitlement.service.spec.ts new file mode 100644 index 0000000..052d350 --- /dev/null +++ b/backend/src/modules/users/entitlement.service.spec.ts @@ -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(); + const entitlements = new Map(); + + 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, + }), + ]); + }); +}); diff --git a/backend/src/modules/users/entitlement.service.ts b/backend/src/modules/users/entitlement.service.ts new file mode 100644 index 0000000..6e583cb --- /dev/null +++ b/backend/src/modules/users/entitlement.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.setDirectEntitlement(userId, entitlementKey, true, source); + } + + async revokeEntitlement( + userId: string, + entitlementKey: EntitlementKey, + source: EntitlementSource = ENTITLEMENT_SOURCES.MANUAL, + ): Promise { + return this.setDirectEntitlement(userId, entitlementKey, false, source); + } + + async syncEntitlementsForSubscription( + userId: string, + now = new Date(), + ): Promise { + 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 { + 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, + }, + }); + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 52d858f..c49420b 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -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, ],