Add Paddle checkout foundation

This commit is contained in:
diyaa 2026-07-09 11:18:14 +02:00
parent ebdede8c79
commit db83d3612d
17 changed files with 1427 additions and 39 deletions

View File

@ -1,6 +1,51 @@
{
"openapi": "3.0.0",
"paths": {
"/api/v1/billing/checkout": {
"post": {
"operationId": "BillingController_createCheckout_v1",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateBillingCheckoutRequestDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateBillingCheckoutResponseDto"
}
}
}
},
"400": {
"description": "Invalid checkout request"
},
"401": {
"description": "Account authentication required"
},
"502": {
"description": "Billing provider customer or checkout creation failed"
}
},
"security": [
{
"bearer": []
}
],
"tags": [
"billing"
]
}
},
"/api/v1/me": {
"get": {
"operationId": "AccountController_getMe_v1",
@ -506,10 +551,45 @@
"scheme": "bearer",
"bearerFormat": "Bearer",
"type": "http",
"description": "Device access token"
"description": "Device access token or account access token"
}
},
"schemas": {
"CreateBillingCheckoutRequestDto": {
"type": "object",
"properties": {
"plan": {
"type": "string",
"enum": [
"PRO_MONTHLY",
"PRO_YEARLY"
]
}
},
"required": [
"plan"
]
},
"CreateBillingCheckoutResponseDto": {
"type": "object",
"properties": {
"plan": {
"type": "string",
"enum": [
"PRO_MONTHLY",
"PRO_YEARLY"
]
},
"checkoutUrl": {
"type": "string",
"example": "https://sandbox-checkout.paddle.com/checkout/txn_123"
}
},
"required": [
"plan",
"checkoutUrl"
]
},
"CurrentAccountResponseDto": {
"type": "object",
"properties": {

View File

@ -0,0 +1,45 @@
CREATE TABLE "billing_customers" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"provider" "BillingProvider" NOT NULL,
"provider_customer_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_customers_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "billing_customers_user_id_key"
ON "billing_customers"("user_id");
CREATE UNIQUE INDEX "billing_customers_provider_customer_id_key"
ON "billing_customers"("provider_customer_id");
ALTER TABLE "billing_customers"
ADD CONSTRAINT "billing_customers_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
INSERT INTO "billing_customers" (
"id",
"user_id",
"provider",
"provider_customer_id",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid(),
"user_id",
"provider",
"provider_customer_id",
NOW(),
NOW()
FROM "user_subscriptions"
WHERE "provider_customer_id" IS NOT NULL
ON CONFLICT ("user_id") DO NOTHING;
DROP INDEX "user_subscriptions_provider_customer_id_key";
ALTER TABLE "user_subscriptions"
DROP COLUMN "provider_customer_id";

View File

@ -34,6 +34,7 @@ model User {
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
accountSessions AccountSession[]
billingCustomer BillingCustomer?
subscription UserSubscription?
entitlements UserEntitlement[]
@ -304,11 +305,22 @@ model AccountSession {
@@map("account_sessions")
}
model BillingCustomer {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
provider BillingProvider
providerCustomerId String @unique @map("provider_customer_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@map("billing_customers")
}
model UserSubscription {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
provider BillingProvider
providerCustomerId String? @unique @map("provider_customer_id")
providerSubscriptionId String? @unique @map("provider_subscription_id")
plan SubscriptionPlan
status SubscriptionStatus

View File

@ -37,7 +37,7 @@ async function generate(): Promise<void> {
type: 'http',
scheme: 'bearer',
bearerFormat: 'Bearer',
description: 'Device access token',
description: 'Device access token or account access token',
},
'bearer',
)

View File

@ -42,7 +42,7 @@ export async function createApp(): Promise<NestExpressApplication> {
type: 'http',
scheme: 'bearer',
bearerFormat: 'Bearer',
description: 'Device access token',
description: 'Device access token or account access token',
},
'bearer',
)

View File

@ -1,20 +1,35 @@
export const BILLING_PROVIDER = Symbol('BILLING_PROVIDER');
/**
* Provider-neutral contract for future billing integrations.
*
* Arguments and return values intentionally remain opaque until the billing
* domain models are introduced. This keeps the foundation internal and avoids
* prematurely exposing a provider-specific API.
*/
export interface BillingCustomerInput {
email: string;
name: string;
accountId: string;
}
export interface BillingCustomer {
id: string;
}
export interface BillingCheckoutSessionInput {
customerId: string;
priceId: string;
accountId: string;
}
export interface BillingCheckoutSession {
url: string;
}
export interface BillingProvider {
createCustomer(...args: unknown[]): Promise<unknown>;
createCustomer(input: BillingCustomerInput): Promise<BillingCustomer>;
createSubscription(...args: unknown[]): Promise<unknown>;
updateSubscription(...args: unknown[]): Promise<unknown>;
cancelSubscription(...args: unknown[]): Promise<unknown>;
reactivateSubscription(...args: unknown[]): Promise<unknown>;
getSubscription(...args: unknown[]): Promise<unknown>;
createCheckoutSession(...args: unknown[]): Promise<unknown>;
createCheckoutSession(
input: BillingCheckoutSessionInput,
): Promise<BillingCheckoutSession>;
createCustomerPortalSession(...args: unknown[]): Promise<unknown>;
validateWebhookSignature(...args: unknown[]): Promise<unknown>;
handleWebhookEvent(...args: unknown[]): Promise<unknown>;

View File

@ -1,6 +1,10 @@
import { Inject, Injectable } from '@nestjs/common';
import {
BILLING_PROVIDER,
BillingCheckoutSession,
BillingCheckoutSessionInput,
BillingCustomer,
BillingCustomerInput,
BillingProvider,
} from './billing-provider.interface';
@ -12,8 +16,8 @@ export class BillingProviderService implements BillingProvider {
private readonly provider: BillingProvider,
) {}
createCustomer(...args: unknown[]): Promise<unknown> {
return this.provider.createCustomer(...args);
createCustomer(input: BillingCustomerInput): Promise<BillingCustomer> {
return this.provider.createCustomer(input);
}
createSubscription(...args: unknown[]): Promise<unknown> {
@ -36,8 +40,10 @@ export class BillingProviderService implements BillingProvider {
return this.provider.getSubscription(...args);
}
createCheckoutSession(...args: unknown[]): Promise<unknown> {
return this.provider.createCheckoutSession(...args);
createCheckoutSession(
input: BillingCheckoutSessionInput,
): Promise<BillingCheckoutSession> {
return this.provider.createCheckoutSession(input);
}
createCustomerPortalSession(...args: unknown[]): Promise<unknown> {

View File

@ -0,0 +1,42 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import {
ApiBadGatewayResponse,
ApiBadRequestResponse,
ApiBearerAuth,
ApiCreatedResponse,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { AccountAuthGuard } from '../auth/account-auth.guard';
import { CurrentAccount } from '../auth/current-account.decorator';
import type { AccountAuthContext } from '../auth/account-auth-context';
import {
CreateBillingCheckoutRequestDto,
CreateBillingCheckoutResponseDto,
} from './billing.dto';
import { BillingService } from './billing.service';
@ApiTags('billing')
@Controller({
path: 'billing',
version: '1',
})
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Post('checkout')
@UseGuards(AccountAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: CreateBillingCheckoutResponseDto })
@ApiUnauthorizedResponse({ description: 'Account authentication required' })
@ApiBadRequestResponse({ description: 'Invalid checkout request' })
@ApiBadGatewayResponse({
description: 'Billing provider customer or checkout creation failed',
})
async createCheckout(
@CurrentAccount() account: AccountAuthContext,
@Body() body: CreateBillingCheckoutRequestDto,
): Promise<CreateBillingCheckoutResponseDto> {
return this.billingService.createCheckout(account, body.plan);
}
}

View File

@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsUrl } from 'class-validator';
export enum BillingCheckoutPlan {
PRO_MONTHLY = 'PRO_MONTHLY',
PRO_YEARLY = 'PRO_YEARLY',
}
export class CreateBillingCheckoutRequestDto {
@ApiProperty({ enum: BillingCheckoutPlan })
@IsEnum(BillingCheckoutPlan)
plan!: BillingCheckoutPlan;
}
export class CreateBillingCheckoutResponseDto {
@ApiProperty({ enum: BillingCheckoutPlan })
plan!: BillingCheckoutPlan;
@ApiProperty({
example: 'https://sandbox-checkout.paddle.com/checkout/txn_123',
})
@IsUrl({
require_tld: false,
require_protocol: true,
})
checkoutUrl!: string;
}

View File

@ -2,8 +2,11 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { NotImplementedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AppConfigService } from '../config/config.service';
import {
BILLING_PROVIDER,
BillingCheckoutSessionInput,
BillingCustomerInput,
BillingProvider,
} from './billing-provider.interface';
import { BillingProviderService } from './billing-provider.service';
@ -11,13 +14,11 @@ import { BillingModule } from './billing.module';
import { PaddleBillingProvider } from './paddle-billing.provider';
const PROVIDER_METHODS = [
'createCustomer',
'createSubscription',
'updateSubscription',
'cancelSubscription',
'reactivateSubscription',
'getSubscription',
'createCheckoutSession',
'createCustomerPortalSession',
'validateWebhookSignature',
'handleWebhookEvent',
@ -31,7 +32,13 @@ describe('BillingModule', () => {
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [BillingModule],
}).compile();
})
.overrideProvider(AppConfigService)
.useValue({
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
})
.compile();
provider = moduleRef.get(PaddleBillingProvider);
service = moduleRef.get(BillingProviderService);
});
@ -61,6 +68,68 @@ describe('BillingModule', () => {
expect(selectedProvider).toBe(provider);
});
it('creates customers through the configured Paddle API', async () => {
const input: BillingCustomerInput = {
email: 'owner@example.com',
name: 'Velody Owner',
accountId: 'user-1',
};
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue({
ok: true,
json: async () => ({
data: {
id: 'ctm_01hv6y1jedq4p1n0yqn5ba3ky4',
},
}),
} as Response);
await expect(service.createCustomer(input)).resolves.toEqual({
id: 'ctm_01hv6y1jedq4p1n0yqn5ba3ky4',
});
expect(fetchRequest).toHaveBeenCalledWith(
'https://sandbox-api.paddle.com/customers',
expect.objectContaining({
method: 'POST',
}),
);
fetchRequest.mockRestore();
});
it('creates checkout sessions through the configured Paddle API', async () => {
const input: BillingCheckoutSessionInput = {
customerId: 'ctm_01hv6y1jedq4p1n0yqn5ba3ky4',
priceId: 'pri_01gsz96z29d88jrmsf2ztbfgjg',
accountId: 'user-1',
};
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValue({
ok: true,
json: async () => ({
data: {
checkout: {
url: 'https://checkout.paddle.test/session',
},
},
}),
} as Response);
await expect(service.createCheckoutSession(input)).resolves.toEqual({
url: 'https://checkout.paddle.test/session',
});
expect(fetchRequest).toHaveBeenCalledWith(
'https://sandbox-api.paddle.com/transactions',
expect.objectContaining({
method: 'POST',
}),
);
fetchRequest.mockRestore();
});
it.each(PROVIDER_METHODS)(
'throws NotImplementedException from provider method %s',
async (method) => {
@ -113,7 +182,7 @@ describe('BillingModule', () => {
);
});
it('performs no HTTP requests while all operations reject', async () => {
it('performs no HTTP requests while unimplemented operations reject', async () => {
const http = jest.requireActual<typeof import('node:http')>('node:http');
const https = jest.requireActual<typeof import('node:https')>('node:https');
const httpRequest = jest.spyOn(http, 'request');

View File

@ -1,9 +1,16 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { AuthModule } from '../auth/auth.module';
import { AppConfigModule } from '../config/config.module';
import { BILLING_PROVIDER } from './billing-provider.interface';
import { BillingController } from './billing.controller';
import { BillingProviderService } from './billing-provider.service';
import { BillingService } from './billing.service';
import { PaddleBillingProvider } from './paddle-billing.provider';
@Module({
imports: [AppConfigModule, PrismaModule, AuthModule],
controllers: [BillingController],
providers: [
PaddleBillingProvider,
{
@ -11,7 +18,8 @@ import { PaddleBillingProvider } from './paddle-billing.provider';
useExisting: PaddleBillingProvider,
},
BillingProviderService,
BillingService,
],
exports: [BillingProviderService],
exports: [BillingProviderService, BillingService],
})
export class BillingModule {}

View File

@ -0,0 +1,283 @@
import {
BadGatewayException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { BillingProvider as BillingProviderKind, UserAccountStatus } from '@prisma/client';
import { BillingCheckoutPlan } from './billing.dto';
import { BillingProviderService } from './billing-provider.service';
import { BillingService } from './billing.service';
describe('BillingService', () => {
function buildService() {
const userFindUnique = jest.fn();
const billingCustomerCreate = jest.fn();
const userSubscriptionUpsert = jest.fn();
const userEntitlementCreateMany = jest.fn();
const prismaService = {
user: {
findUnique: userFindUnique,
},
billingCustomer: {
create: billingCustomerCreate,
},
userSubscription: {
upsert: userSubscriptionUpsert,
},
userEntitlement: {
createMany: userEntitlementCreateMany,
},
} as any;
const billingProviderService = {
createCustomer: jest.fn(),
createCheckoutSession: jest.fn(),
} as unknown as BillingProviderService;
const configService = {
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
} as any;
return {
service: new BillingService(
prismaService,
billingProviderService,
configService,
),
prismaService,
billingProviderService: billingProviderService as any,
configService,
mocks: {
billingCustomerCreate,
userSubscriptionUpsert,
userEntitlementCreateMany,
},
};
}
it('creates a checkout with a new Paddle customer when none exists', async () => {
const { service, prismaService, billingProviderService, mocks } =
buildService();
prismaService.user.findUnique.mockResolvedValue({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.ACTIVE,
oauthIdentities: [{ email: 'owner@example.com' }],
billingCustomer: null,
});
billingProviderService.createCustomer.mockResolvedValue({ id: 'ctm_123' });
mocks.billingCustomerCreate.mockResolvedValue({
providerCustomerId: 'ctm_123',
});
billingProviderService.createCheckoutSession.mockResolvedValue({
url: 'https://checkout.example/session',
});
await expect(
service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).resolves.toEqual({
plan: BillingCheckoutPlan.PRO_MONTHLY,
checkoutUrl: 'https://checkout.example/session',
});
expect(billingProviderService.createCustomer).toHaveBeenCalledWith({
email: 'owner@example.com',
name: 'Velody Owner',
accountId: 'user-1',
});
expect(mocks.billingCustomerCreate).toHaveBeenCalledWith({
data: {
userId: 'user-1',
provider: BillingProviderKind.PADDLE,
providerCustomerId: 'ctm_123',
},
select: {
providerCustomerId: true,
},
});
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
expect(billingProviderService.createCheckoutSession).toHaveBeenCalledWith({
customerId: 'ctm_123',
priceId: 'pri_monthly',
accountId: 'user-1',
});
});
it('reuses an existing Paddle customer', async () => {
const { service, prismaService, billingProviderService, mocks } =
buildService();
prismaService.user.findUnique.mockResolvedValue({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.ACTIVE,
oauthIdentities: [{ email: 'owner@example.com' }],
billingCustomer: {
id: 'billing-customer-1',
providerCustomerId: 'ctm_existing',
},
});
billingProviderService.createCheckoutSession.mockResolvedValue({
url: 'https://checkout.example/existing',
});
await service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_YEARLY,
);
expect(billingProviderService.createCustomer).not.toHaveBeenCalled();
expect(mocks.billingCustomerCreate).not.toHaveBeenCalled();
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
expect(billingProviderService.createCheckoutSession).toHaveBeenCalledWith({
customerId: 'ctm_existing',
priceId: 'pri_yearly',
accountId: 'user-1',
});
});
it('rejects checkout when the account has no stored email', async () => {
const { service, prismaService, mocks } = buildService();
prismaService.user.findUnique.mockResolvedValue({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.ACTIVE,
oauthIdentities: [],
billingCustomer: null,
});
await expect(
service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).rejects.toThrow(
new BadRequestException('Account email is required for checkout'),
);
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
});
it('maps Paddle customer creation failures to a gateway error', async () => {
const { service, prismaService, billingProviderService, mocks } =
buildService();
prismaService.user.findUnique.mockResolvedValue({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.ACTIVE,
oauthIdentities: [{ email: 'owner@example.com' }],
billingCustomer: null,
});
billingProviderService.createCustomer.mockRejectedValue(new Error('boom'));
await expect(
service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).rejects.toThrow(
new BadGatewayException('Unable to create billing customer'),
);
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
});
it('maps checkout session failures to a gateway error', async () => {
const { service, prismaService, billingProviderService, mocks } =
buildService();
prismaService.user.findUnique.mockResolvedValue({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.ACTIVE,
oauthIdentities: [{ email: 'owner@example.com' }],
billingCustomer: {
id: 'billing-customer-1',
providerCustomerId: 'ctm_existing',
},
});
billingProviderService.createCheckoutSession.mockRejectedValue(
new Error('boom'),
);
await expect(
service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).rejects.toThrow(
new BadGatewayException('Unable to create checkout session'),
);
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
});
it('rejects missing or inactive accounts', async () => {
const { service, prismaService, mocks } = buildService();
prismaService.user.findUnique.mockResolvedValueOnce(null);
await expect(
service.createCheckout(
{
userId: 'missing-user',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).rejects.toThrow(new NotFoundException('Account not found'));
prismaService.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
displayName: 'Velody Owner',
accountStatus: UserAccountStatus.LOCKED,
oauthIdentities: [{ email: 'owner@example.com' }],
billingCustomer: null,
});
await expect(
service.createCheckout(
{
userId: 'user-1',
sessionId: 'session-1',
accessTokenVersion: 1,
},
BillingCheckoutPlan.PRO_MONTHLY,
),
).rejects.toThrow(new BadRequestException('Account is not active'));
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,171 @@
import {
BadGatewayException,
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
BillingProvider as BillingProviderKind,
UserAccountStatus,
} from '@prisma/client';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import type { AccountAuthContext } from '../auth/account-auth-context';
import { AppConfigService } from '../config/config.service';
import { BillingProviderService } from './billing-provider.service';
import {
BillingCheckoutPlan,
CreateBillingCheckoutResponseDto,
} from './billing.dto';
interface CheckoutAccountRecord {
id: string;
displayName: string;
accountStatus: UserAccountStatus;
oauthIdentities: Array<{
email: string | null;
}>;
billingCustomer: {
id: string;
providerCustomerId: string | null;
} | null;
}
@Injectable()
export class BillingService {
constructor(
private readonly prismaService: PrismaService,
private readonly billingProviderService: BillingProviderService,
private readonly configService: AppConfigService,
) {}
async createCheckout(
account: AccountAuthContext,
request: BillingCheckoutPlan,
): Promise<CreateBillingCheckoutResponseDto> {
const checkoutAccount = await this.getCheckoutAccount(account.userId);
const email = this.getCheckoutEmail(checkoutAccount);
const providerCustomerId =
checkoutAccount.billingCustomer?.providerCustomerId ??
(await this.createAndPersistCustomer(checkoutAccount.id, {
email,
name: checkoutAccount.displayName,
}));
try {
const session = await this.billingProviderService.createCheckoutSession({
customerId: providerCustomerId,
priceId: this.resolvePriceId(request),
accountId: checkoutAccount.id,
});
return {
plan: request,
checkoutUrl: session.url,
};
} catch {
throw new BadGatewayException('Unable to create checkout session');
}
}
private async getCheckoutAccount(
userId: string,
): Promise<CheckoutAccountRecord> {
const account = await this.prismaService.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
displayName: true,
accountStatus: true,
oauthIdentities: {
select: {
email: true,
},
orderBy: {
createdAt: 'asc',
},
},
billingCustomer: {
select: {
id: true,
providerCustomerId: true,
},
},
},
});
if (!account) {
throw new NotFoundException('Account not found');
}
if (account.accountStatus !== UserAccountStatus.ACTIVE) {
throw new BadRequestException('Account is not active');
}
return account;
}
private getCheckoutEmail(account: CheckoutAccountRecord): string {
const email = account.oauthIdentities.find((identity) => identity.email)
?.email;
if (!email) {
throw new BadRequestException('Account email is required for checkout');
}
return email;
}
private async createAndPersistCustomer(
userId: string,
customer: {
email: string;
name: string;
},
): Promise<string> {
try {
const createdCustomer = await this.billingProviderService.createCustomer({
email: customer.email,
name: customer.name,
accountId: userId,
});
const billingCustomer = await this.prismaService.billingCustomer.create({
data: {
userId,
provider: BillingProviderKind.PADDLE,
providerCustomerId: createdCustomer.id,
},
select: {
providerCustomerId: true,
},
});
if (!billingCustomer.providerCustomerId) {
throw new BadGatewayException('Unable to persist billing customer');
}
return billingCustomer.providerCustomerId;
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
if (error instanceof BadGatewayException) {
throw error;
}
throw new BadGatewayException('Unable to create billing customer');
}
}
private resolvePriceId(plan: BillingCheckoutPlan): string {
switch (plan) {
case BillingCheckoutPlan.PRO_MONTHLY:
return this.configService.getPaddleProMonthlyPriceId();
case BillingCheckoutPlan.PRO_YEARLY:
return this.configService.getPaddleProYearlyPriceId();
}
}
}

View File

@ -1,10 +1,61 @@
import { Injectable, NotImplementedException } from '@nestjs/common';
import { BillingProvider } from './billing-provider.interface';
import {
Injectable,
InternalServerErrorException,
NotImplementedException,
} from '@nestjs/common';
import { AppConfigService } from '../config/config.service';
import {
BillingCheckoutSession,
BillingCheckoutSessionInput,
BillingCustomer,
BillingCustomerInput,
BillingProvider,
} from './billing-provider.interface';
interface PaddleApiEnvelope<T> {
data?: T;
error?: {
type?: string;
code?: string;
detail?: string;
};
}
interface PaddleCustomerResponse {
id: string;
}
interface PaddleTransactionResponse {
checkout?: {
url?: string | null;
} | null;
}
@Injectable()
export class PaddleBillingProvider implements BillingProvider {
async createCustomer(..._args: unknown[]): Promise<never> {
throw new NotImplementedException();
constructor(private readonly configService: AppConfigService) {}
async createCustomer(input: BillingCustomerInput): Promise<BillingCustomer> {
const response = await this.request<PaddleCustomerResponse>('/customers', {
method: 'POST',
body: JSON.stringify({
email: input.email,
name: input.name,
custom_data: {
velodyAccountId: input.accountId,
},
}),
});
if (!response.data?.id) {
throw new InternalServerErrorException(
'Billing provider returned an invalid customer response',
);
}
return {
id: response.data.id,
};
}
async createSubscription(..._args: unknown[]): Promise<never> {
@ -27,8 +78,40 @@ export class PaddleBillingProvider implements BillingProvider {
throw new NotImplementedException();
}
async createCheckoutSession(..._args: unknown[]): Promise<never> {
throw new NotImplementedException();
async createCheckoutSession(
input: BillingCheckoutSessionInput,
): Promise<BillingCheckoutSession> {
const response = await this.request<PaddleTransactionResponse>(
'/transactions',
{
method: 'POST',
body: JSON.stringify({
items: [
{
price_id: input.priceId,
quantity: 1,
},
],
customer_id: input.customerId,
collection_mode: 'automatic',
custom_data: {
velodyAccountId: input.accountId,
},
}),
},
);
const checkoutUrl = response.data?.checkout?.url;
if (!checkoutUrl) {
throw new InternalServerErrorException(
'Billing provider returned an invalid checkout response',
);
}
return {
url: checkoutUrl,
};
}
async createCustomerPortalSession(..._args: unknown[]): Promise<never> {
@ -42,4 +125,40 @@ export class PaddleBillingProvider implements BillingProvider {
async handleWebhookEvent(..._args: unknown[]): Promise<never> {
throw new NotImplementedException();
}
private async request<T>(
path: string,
init: RequestInit,
): Promise<PaddleApiEnvelope<T>> {
const response = await fetch(`${this.getBaseUrl()}${path}`, {
...init,
headers: {
Authorization: `Bearer ${this.configService.getPaddleApiKey()}`,
'Content-Type': 'application/json',
Accept: 'application/json',
...(init.headers ?? {}),
},
});
const payload = (await response.json()) as PaddleApiEnvelope<T>;
if (!response.ok) {
const detail =
payload.error?.detail ??
payload.error?.code ??
payload.error?.type ??
'Unknown billing provider error';
throw new InternalServerErrorException(detail);
}
return payload;
}
private getBaseUrl(): string {
if (this.configService.getPaddleEnvironment() === 'production') {
return 'https://api.paddle.com';
}
return 'https://sandbox-api.paddle.com';
}
}

View File

@ -0,0 +1,47 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
describe('billing customer foundation correction migration', () => {
let migrationSql: string;
beforeAll(async () => {
migrationSql = await readFile(
join(
process.cwd(),
'prisma/migrations/20260708130000_milestone121_checkout_customer_foundation_correction/migration.sql',
),
'utf8',
);
});
it('creates the billing_customers table', () => {
expect(migrationSql).toContain(`CREATE TABLE "billing_customers"`);
});
it('enforces one billing customer per user and unique provider customer ids', () => {
expect(migrationSql).toContain(
`CREATE UNIQUE INDEX "billing_customers_user_id_key"`,
);
expect(migrationSql).toContain(
`CREATE UNIQUE INDEX "billing_customers_provider_customer_id_key"`,
);
});
it('relates billing customers to users with cascading deletes', () => {
expect(migrationSql).toContain(
`ADD CONSTRAINT "billing_customers_user_id_fkey"`,
);
expect(migrationSql).toContain(`REFERENCES "users"("id")`);
expect(migrationSql).toContain(`ON DELETE CASCADE`);
expect(migrationSql).toContain(`ON UPDATE CASCADE`);
});
it('removes provider customer identity from user_subscriptions', () => {
expect(migrationSql).toContain(
`DROP INDEX "user_subscriptions_provider_customer_id_key";`,
);
expect(migrationSql).toContain(
`DROP COLUMN "provider_customer_id";`,
);
});
});

View File

@ -31,24 +31,17 @@ describe('subscription foundation migration', () => {
);
});
it('enforces one subscription per user and unique provider identifiers', () => {
it('enforces one subscription per user and unique provider subscription identifiers', () => {
expect(migrationSql).toContain(
`CREATE UNIQUE INDEX "user_subscriptions_user_id_key"`,
);
expect(migrationSql).toContain(
`CREATE UNIQUE INDEX "user_subscriptions_provider_customer_id_key"`,
);
expect(migrationSql).toContain(
`CREATE UNIQUE INDEX "user_subscriptions_provider_subscription_id_key"`,
);
});
it('keeps provider identifiers nullable', () => {
expect(migrationSql).toContain(`"provider_customer_id" TEXT,`);
it('keeps the provider subscription identifier nullable', () => {
expect(migrationSql).toContain(`"provider_subscription_id" TEXT,`);
expect(migrationSql).not.toContain(
`"provider_customer_id" TEXT NOT NULL`,
);
expect(migrationSql).not.toContain(
`"provider_subscription_id" TEXT NOT NULL`,
);

View File

@ -4,6 +4,8 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { Readable } from 'node:stream';
import {
BadGatewayException,
ExecutionContext,
ForbiddenException,
NotFoundException,
UnauthorizedException,
@ -17,8 +19,15 @@ import { AppModule } from '../../src/app.module';
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto';
import { AccountAuthGuard } from '../../src/modules/auth/account-auth.guard';
import { AuthenticationRuntimeService } from '../../src/modules/auth/authentication-runtime.service';
import { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
import { ArtworkController } from '../../src/modules/artwork/artwork.controller';
import { BillingController } from '../../src/modules/billing/billing.controller';
import {
BillingCheckoutPlan,
CreateBillingCheckoutRequestDto,
} from '../../src/modules/billing/billing.dto';
import { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller';
@ -91,6 +100,10 @@ function createPrismaMock() {
const artworkAssets = new Map<string, any>();
const uploadSessions = new Map<string, any>();
const libraryEvents = new Map<bigint, any>();
const billingCustomers = new Map<string, any>();
const userSubscriptions = new Map<string, any>();
const userOAuthIdentities = new Map<string, any>();
const userEntitlements = new Map<string, any>();
let nextLibraryEventId = 1n;
const createUserRecord = (data: Record<string, any>) => {
@ -143,8 +156,58 @@ function createPrismaMock() {
return created;
}),
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const attachRelations = (user: Record<string, any> | null) => {
if (!user || !select) {
return applySelect(user, select);
}
const baseSelect = Object.fromEntries(
Object.entries(select).filter(([, value]) => value === true),
) as Record<string, boolean>;
const selectedUser = applySelect(user, baseSelect) as Record<
string,
any
>;
if (select.oauthIdentities) {
const identities = [...userOAuthIdentities.values()]
.filter((identity) => identity.userId === user.id)
.sort(
(lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime(),
)
.map((identity) =>
applySelect(identity, select.oauthIdentities.select),
);
selectedUser.oauthIdentities = identities;
}
if (select.billingCustomer) {
const billingCustomer =
[...billingCustomers.values()].find(
(record) => record.userId === user.id,
) ?? null;
selectedUser.billingCustomer = applySelect(
billingCustomer,
select.billingCustomer.select,
);
}
if (select.subscription) {
const subscription =
[...userSubscriptions.values()].find(
(record) => record.userId === user.id,
) ?? null;
selectedUser.subscription = applySelect(
subscription,
select.subscription.select,
);
}
return selectedUser;
};
if (where.id) {
return applySelect(users.get(where.id) ?? null, select);
return attachRelations(users.get(where.id) ?? null);
}
if (where.slug) {
@ -152,7 +215,7 @@ function createPrismaMock() {
[...users.values()].find((user) => user.slug === where.slug) ??
null;
return applySelect(matchingUser, select);
return attachRelations(matchingUser);
}
return null;
@ -187,6 +250,40 @@ function createPrismaMock() {
return updated;
}),
},
userOAuthIdentity: {
create: jest.fn().mockImplementation(async ({ data, select }) => {
const record = {
id: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
userOAuthIdentities.set(record.id, record);
return applySelect(record, select);
}),
findUnique: jest.fn().mockImplementation(async ({ where, include }) => {
const record =
[...userOAuthIdentities.values()].find(
(identity) =>
identity.provider === where.provider_providerSubject?.provider &&
identity.providerSubject ===
where.provider_providerSubject?.providerSubject,
) ?? null;
if (!record) {
return null;
}
if (include?.user) {
return {
...record,
user: applySelect(users.get(record.userId) ?? null, include.user.select),
};
}
return record;
}),
},
device: {
create: jest.fn().mockImplementation(async ({ data }) => {
const record = {
@ -225,6 +322,92 @@ function createPrismaMock() {
return updated;
}),
},
billingCustomer: {
create: jest.fn().mockImplementation(async ({ data, select }) => {
const record = {
id: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
...data,
};
billingCustomers.set(record.id, record);
return applySelect(record, select);
}),
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const record =
[...billingCustomers.values()].find(
(billingCustomer) =>
billingCustomer.userId === where.userId ||
billingCustomer.providerCustomerId === where.providerCustomerId,
) ?? null;
return applySelect(record, select);
}),
},
userSubscription: {
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const record =
[...userSubscriptions.values()].find(
(subscription) => subscription.userId === where.userId,
) ?? null;
return applySelect(record, select);
}),
upsert: jest.fn().mockImplementation(async ({ where, update, create, select }) => {
const existing =
[...userSubscriptions.values()].find(
(subscription) => subscription.userId === where.userId,
) ?? null;
const now = new Date();
const record = existing
? {
...existing,
...update,
updatedAt: now,
}
: {
id: randomUUID(),
createdAt: now,
updatedAt: now,
...create,
};
userSubscriptions.set(record.id, record);
return applySelect(record, select);
}),
},
userEntitlement: {
findMany: jest.fn().mockImplementation(async ({ where }) =>
[...userEntitlements.values()].filter((entitlement) =>
where?.userId ? entitlement.userId === where.userId : true,
),
),
createMany: jest.fn().mockImplementation(async ({ data }) => {
for (const entry of data) {
const id = `${entry.userId}:${entry.entitlementKey}`;
userEntitlements.set(id, {
id,
createdAt: new Date(),
updatedAt: new Date(),
...entry,
});
}
return { count: data.length };
}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockImplementation(async ({ create }) => {
const id = `${create.userId}:${create.entitlementKey}`;
const record = {
id,
createdAt: new Date(),
updatedAt: new Date(),
...create,
};
userEntitlements.set(id, record);
return record;
}),
},
track: {
findMany: jest.fn().mockImplementation(async ({ where }) => {
return [...tracks.values()]
@ -448,6 +631,10 @@ function createPrismaMock() {
artworkAssets,
uploadSessions,
libraryEvents,
billingCustomers,
userSubscriptions,
userOAuthIdentities,
userEntitlements,
},
};
}
@ -458,6 +645,7 @@ describe('Velody API wiring (e2e)', () => {
let assetsController: AssetsController;
let accountController: AccountController;
let artworkController: ArtworkController;
let billingController: BillingController;
let healthController: HealthController;
let devicesController: DevicesController;
let libraryController: LibraryController;
@ -466,6 +654,8 @@ describe('Velody API wiring (e2e)', () => {
let uploadsService: UploadsService;
let requestContextService: RequestContextService;
let deviceAuthService: DeviceAuthService;
let authenticationRuntimeService: AuthenticationRuntimeService;
let accountAuthGuard: AccountAuthGuard;
let prismaState: ReturnType<typeof createPrismaMock>['state'];
let storageRoot: string;
@ -534,6 +724,46 @@ describe('Velody API wiring (e2e)', () => {
});
}
function createAccountExecutionContext(authorizationHeader?: string) {
const request = {
headers: {
authorization: authorizationHeader,
},
} as any;
return {
request,
context: {
switchToHttp: () => ({
getRequest: () => request,
}),
} as ExecutionContext,
};
}
async function runBillingCheckoutRequest(
authorizationHeader: string | undefined,
body: unknown,
) {
const validationPipe = new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
});
const { request, context } =
createAccountExecutionContext(authorizationHeader);
await accountAuthGuard.canActivate(context);
const validatedBody = await validationPipe.transform(body, {
type: 'body',
metatype: CreateBillingCheckoutRequestDto,
});
return billingController.createCheckout(
request.accountAuthContext,
validatedBody,
);
}
beforeEach(async () => {
const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock;
@ -549,6 +779,12 @@ describe('Velody API wiring (e2e)', () => {
appVersion: '0.1.0',
maxUploadSizeBytes: 1024 * 1024 * 1024,
storageRoot,
accountAccessTokenSecret: 'test-account-access-secret',
accountAccessTokenTtlSeconds: 900,
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
})
.overrideProvider(PrismaService)
.useValue(prismaMock)
@ -574,6 +810,7 @@ describe('Velody API wiring (e2e)', () => {
assetsController = moduleRef.get(AssetsController);
accountController = moduleRef.get(AccountController);
artworkController = moduleRef.get(ArtworkController);
billingController = moduleRef.get(BillingController);
healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController);
@ -582,6 +819,8 @@ describe('Velody API wiring (e2e)', () => {
uploadsService = moduleRef.get(UploadsService);
requestContextService = moduleRef.get(RequestContextService);
deviceAuthService = moduleRef.get(DeviceAuthService);
authenticationRuntimeService = moduleRef.get(AuthenticationRuntimeService);
accountAuthGuard = moduleRef.get(AccountAuthGuard);
});
afterEach(async () => {
@ -1706,6 +1945,238 @@ describe('Velody API wiring (e2e)', () => {
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 401 when billing checkout is requested without Authorization', async () => {
await expect(
runBillingCheckoutRequest(undefined, {
plan: BillingCheckoutPlan.PRO_MONTHLY,
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('returns 400 when billing checkout plan is invalid', async () => {
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: 'INVALID_PLAN',
}),
).rejects.toMatchObject({
response: {
message: expect.arrayContaining([
'plan must be one of the following values: PRO_MONTHLY, PRO_YEARLY',
]),
},
});
});
it('creates a checkout for an authenticated account with a new Paddle customer', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
id: 'ctm_new_customer',
},
}),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
checkout: {
url: 'https://checkout.paddle.test/new-session',
},
},
}),
} as Response);
const response = await runBillingCheckoutRequest(
'Bearer valid-account-token',
{
plan: BillingCheckoutPlan.PRO_MONTHLY,
},
);
expect(response).toEqual({
plan: BillingCheckoutPlan.PRO_MONTHLY,
checkoutUrl: 'https://checkout.paddle.test/new-session',
});
expect([...prismaState.billingCustomers.values()][0]).toMatchObject({
userId: prismaState.defaultUser.id,
providerCustomerId: 'ctm_new_customer',
provider: 'PADDLE',
});
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('creates a checkout for an authenticated account with an existing Paddle customer', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.billingCustomers.set('billing-customer-1', {
id: 'billing-customer-1',
userId: prismaState.defaultUser.id,
provider: 'PADDLE',
providerCustomerId: 'ctm_existing_customer',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
checkout: {
url: 'https://checkout.paddle.test/existing-session',
},
},
}),
} as Response);
const response = await runBillingCheckoutRequest(
'Bearer valid-account-token',
{
plan: BillingCheckoutPlan.PRO_YEARLY,
},
);
expect(response).toEqual({
plan: BillingCheckoutPlan.PRO_YEARLY,
checkoutUrl: 'https://checkout.paddle.test/existing-session',
});
expect(fetchRequest).toHaveBeenCalledTimes(1);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('returns 502 when Paddle customer creation fails during billing checkout', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: false,
json: async () => ({
error: {
detail: 'Paddle customer error',
},
}),
} as Response);
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: BillingCheckoutPlan.PRO_MONTHLY,
}),
).rejects.toBeInstanceOf(BadGatewayException);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('returns 502 when Paddle checkout session creation fails during billing checkout', async () => {
prismaState.userOAuthIdentities.set('identity-1', {
id: 'identity-1',
userId: prismaState.defaultUser.id,
provider: 'GOOGLE',
providerSubject: 'subject-1',
email: 'owner@example.com',
createdAt: new Date(),
updatedAt: new Date(),
});
prismaState.billingCustomers.set('billing-customer-1', {
id: 'billing-customer-1',
userId: prismaState.defaultUser.id,
provider: 'PADDLE',
providerCustomerId: 'ctm_existing_customer',
createdAt: new Date(),
updatedAt: new Date(),
});
jest
.spyOn(authenticationRuntimeService, 'validateAccessToken')
.mockResolvedValueOnce({
userId: prismaState.defaultUser.id,
sessionId: 'session-1',
accessTokenVersion: 1,
});
const fetchRequest = jest
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce({
ok: false,
json: async () => ({
error: {
detail: 'Paddle checkout error',
},
}),
} as Response);
await expect(
runBillingCheckoutRequest('Bearer valid-account-token', {
plan: BillingCheckoutPlan.PRO_YEARLY,
}),
).rejects.toBeInstanceOf(BadGatewayException);
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
fetchRequest.mockRestore();
});
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',