Add Paddle checkout foundation
This commit is contained in:
parent
ebdede8c79
commit
db83d3612d
@ -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": {
|
||||
|
||||
@ -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";
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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> {
|
||||
|
||||
42
backend/src/modules/billing/billing.controller.ts
Normal file
42
backend/src/modules/billing/billing.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
backend/src/modules/billing/billing.dto.ts
Normal file
27
backend/src/modules/billing/billing.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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 {}
|
||||
|
||||
283
backend/src/modules/billing/billing.service.spec.ts
Normal file
283
backend/src/modules/billing/billing.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
171
backend/src/modules/billing/billing.service.ts
Normal file
171
backend/src/modules/billing/billing.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
47
backend/src/modules/users/billing-customer-migration.spec.ts
Normal file
47
backend/src/modules/users/billing-customer-migration.spec.ts
Normal 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";`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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`,
|
||||
);
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user