diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 06bb100..1e21523 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -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": { diff --git a/backend/prisma/migrations/20260708130000_milestone121_checkout_customer_foundation_correction/migration.sql b/backend/prisma/migrations/20260708130000_milestone121_checkout_customer_foundation_correction/migration.sql new file mode 100644 index 0000000..ee18530 --- /dev/null +++ b/backend/prisma/migrations/20260708130000_milestone121_checkout_customer_foundation_correction/migration.sql @@ -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"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 87c4437..eefde40 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index abca39e..2df2627 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -37,7 +37,7 @@ async function generate(): Promise { type: 'http', scheme: 'bearer', bearerFormat: 'Bearer', - description: 'Device access token', + description: 'Device access token or account access token', }, 'bearer', ) diff --git a/backend/src/app.factory.ts b/backend/src/app.factory.ts index 88e149c..9c2ee7e 100644 --- a/backend/src/app.factory.ts +++ b/backend/src/app.factory.ts @@ -42,7 +42,7 @@ export async function createApp(): Promise { type: 'http', scheme: 'bearer', bearerFormat: 'Bearer', - description: 'Device access token', + description: 'Device access token or account access token', }, 'bearer', ) diff --git a/backend/src/modules/billing/billing-provider.interface.ts b/backend/src/modules/billing/billing-provider.interface.ts index 2625c18..e434257 100644 --- a/backend/src/modules/billing/billing-provider.interface.ts +++ b/backend/src/modules/billing/billing-provider.interface.ts @@ -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; + createCustomer(input: BillingCustomerInput): Promise; createSubscription(...args: unknown[]): Promise; updateSubscription(...args: unknown[]): Promise; cancelSubscription(...args: unknown[]): Promise; reactivateSubscription(...args: unknown[]): Promise; getSubscription(...args: unknown[]): Promise; - createCheckoutSession(...args: unknown[]): Promise; + createCheckoutSession( + input: BillingCheckoutSessionInput, + ): Promise; createCustomerPortalSession(...args: unknown[]): Promise; validateWebhookSignature(...args: unknown[]): Promise; handleWebhookEvent(...args: unknown[]): Promise; diff --git a/backend/src/modules/billing/billing-provider.service.ts b/backend/src/modules/billing/billing-provider.service.ts index c4b0c2b..d05b351 100644 --- a/backend/src/modules/billing/billing-provider.service.ts +++ b/backend/src/modules/billing/billing-provider.service.ts @@ -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 { - return this.provider.createCustomer(...args); + createCustomer(input: BillingCustomerInput): Promise { + return this.provider.createCustomer(input); } createSubscription(...args: unknown[]): Promise { @@ -36,8 +40,10 @@ export class BillingProviderService implements BillingProvider { return this.provider.getSubscription(...args); } - createCheckoutSession(...args: unknown[]): Promise { - return this.provider.createCheckoutSession(...args); + createCheckoutSession( + input: BillingCheckoutSessionInput, + ): Promise { + return this.provider.createCheckoutSession(input); } createCustomerPortalSession(...args: unknown[]): Promise { diff --git a/backend/src/modules/billing/billing.controller.ts b/backend/src/modules/billing/billing.controller.ts new file mode 100644 index 0000000..f492cb7 --- /dev/null +++ b/backend/src/modules/billing/billing.controller.ts @@ -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 { + return this.billingService.createCheckout(account, body.plan); + } +} diff --git a/backend/src/modules/billing/billing.dto.ts b/backend/src/modules/billing/billing.dto.ts new file mode 100644 index 0000000..f764f9a --- /dev/null +++ b/backend/src/modules/billing/billing.dto.ts @@ -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; +} diff --git a/backend/src/modules/billing/billing.module.spec.ts b/backend/src/modules/billing/billing.module.spec.ts index 7c49c94..8f365fe 100644 --- a/backend/src/modules/billing/billing.module.spec.ts +++ b/backend/src/modules/billing/billing.module.spec.ts @@ -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('node:http'); const https = jest.requireActual('node:https'); const httpRequest = jest.spyOn(http, 'request'); diff --git a/backend/src/modules/billing/billing.module.ts b/backend/src/modules/billing/billing.module.ts index 7708e65..61410a9 100644 --- a/backend/src/modules/billing/billing.module.ts +++ b/backend/src/modules/billing/billing.module.ts @@ -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 {} diff --git a/backend/src/modules/billing/billing.service.spec.ts b/backend/src/modules/billing/billing.service.spec.ts new file mode 100644 index 0000000..c4dd323 --- /dev/null +++ b/backend/src/modules/billing/billing.service.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts new file mode 100644 index 0000000..b1e03ca --- /dev/null +++ b/backend/src/modules/billing/billing.service.ts @@ -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 { + 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 { + 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 { + 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(); + } + } +} diff --git a/backend/src/modules/billing/paddle-billing.provider.ts b/backend/src/modules/billing/paddle-billing.provider.ts index c66c346..bd7698e 100644 --- a/backend/src/modules/billing/paddle-billing.provider.ts +++ b/backend/src/modules/billing/paddle-billing.provider.ts @@ -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 { + 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 { - throw new NotImplementedException(); + constructor(private readonly configService: AppConfigService) {} + + async createCustomer(input: BillingCustomerInput): Promise { + const response = await this.request('/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 { @@ -27,8 +78,40 @@ export class PaddleBillingProvider implements BillingProvider { throw new NotImplementedException(); } - async createCheckoutSession(..._args: unknown[]): Promise { - throw new NotImplementedException(); + async createCheckoutSession( + input: BillingCheckoutSessionInput, + ): Promise { + const response = await this.request( + '/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 { @@ -42,4 +125,40 @@ export class PaddleBillingProvider implements BillingProvider { async handleWebhookEvent(..._args: unknown[]): Promise { throw new NotImplementedException(); } + + private async request( + path: string, + init: RequestInit, + ): Promise> { + 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; + + 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'; + } } diff --git a/backend/src/modules/users/billing-customer-migration.spec.ts b/backend/src/modules/users/billing-customer-migration.spec.ts new file mode 100644 index 0000000..ec4a440 --- /dev/null +++ b/backend/src/modules/users/billing-customer-migration.spec.ts @@ -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";`, + ); + }); +}); diff --git a/backend/src/modules/users/subscription-foundation-migration.spec.ts b/backend/src/modules/users/subscription-foundation-migration.spec.ts index b519950..d8bd693 100644 --- a/backend/src/modules/users/subscription-foundation-migration.spec.ts +++ b/backend/src/modules/users/subscription-foundation-migration.spec.ts @@ -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`, ); diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index b29dcf7..6140539 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -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(); const uploadSessions = new Map(); const libraryEvents = new Map(); + const billingCustomers = new Map(); + const userSubscriptions = new Map(); + const userOAuthIdentities = new Map(); + const userEntitlements = new Map(); let nextLibraryEventId = 1n; const createUserRecord = (data: Record) => { @@ -143,8 +156,58 @@ function createPrismaMock() { return created; }), findUnique: jest.fn().mockImplementation(async ({ where, select }) => { + const attachRelations = (user: Record | null) => { + if (!user || !select) { + return applySelect(user, select); + } + + const baseSelect = Object.fromEntries( + Object.entries(select).filter(([, value]) => value === true), + ) as Record; + 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['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',