Compare commits

..

8 Commits

Author SHA1 Message Date
diyaa
6498b3b38b Add Paddle webhook ingestion foundation 2026-07-11 08:40:31 +02:00
diyaa
db83d3612d Add Paddle checkout foundation 2026-07-09 11:18:14 +02:00
diyaa
ebdede8c79 Update READMEE 2026-07-07 02:24:46 +02:00
diyaa
6f187b061f Remove Legacy storage readiness paths 2026-07-06 12:37:19 +02:00
diyaa
03d32c06ca Add root Docker ignore 2026-07-06 00:15:44 +02:00
diyaa
161b5d141b Fix backend Dockerfile port and runtime handling 2026-07-05 23:57:30 +02:00
diyaa
62859c3c64 Fix middleware wildcard route warning 2026-07-05 23:36:24 +02:00
diyaa
15cb55e648 Update backend environment example 2026-07-05 23:16:02 +02:00
24 changed files with 1956 additions and 64 deletions

18
.dockerignore Normal file
View File

@ -0,0 +1,18 @@
.git
.DS_Store
**/node_modules
**/dist
**/build
**/.build
**/DerivedData
**/.derivedData
**/coverage
**/.next
**/.cache
**/*.log
**/.env
backend/.env
runtime
apps/**/build
packages/apple/**/build
packages/apple/**/.build

View File

@ -2,7 +2,9 @@
Velody is a private music ecosystem for personal use across `macOS`, `iPhone`, and a VPS backend.
This repository currently contains the execution foundation for Phase 1:
The repository now includes a working backend plus Apple clients for local library upload, remote playback, iPhone sync, offline resilience, account foundation work, and early billing groundwork.
## Repository Layout
- `apps/apple` for the native Apple apps workspace
- `packages/apple` for shared Swift packages
@ -19,12 +21,12 @@ This repository currently contains the execution foundation for Phase 1:
- npm 10+
- Docker Desktop
## Local Development
## Local Backend Setup
1. Copy the example environment file:
```bash
cp .env.example .env
cp backend/.env.example backend/.env
```
2. Start Postgres and the API:
@ -33,26 +35,32 @@ cp .env.example .env
docker compose -f infra/docker/compose.local.yml up --build
```
3. Open the Apple workspace:
3. Check backend health:
```bash
curl -i http://localhost:3007/api/v1/health
```
4. Open the Apple workspace:
```bash
open apps/apple/Velody.xcworkspace
```
## Current Scope
The local backend runs on `http://localhost:3007`.
This phase intentionally provides:
## Current Implemented Areas
- backend contracts and scaffolding
- database schema and initial migration
- local filesystem storage readiness checks
- placeholder Apple apps and shared packages
- upload ingest and transfer flow
- Apple playback experience
- iPhone library sync and offline playback resilience
- account and device-linking foundation
- billing and entitlement foundation work
This phase intentionally does not yet implement:
## Not Yet Exposed Public Areas
- folder watching
- upload chunk transfer
- background downloads
- audio playback
The local backend now defaults to `http://localhost:3007`.
- public login and signup APIs
- public account authentication APIs
- Paddle checkout
- Paddle webhooks
- public billing APIs

View File

@ -5,3 +5,12 @@ STORAGE_ROOT=../runtime/storage
PUBLIC_BASE_URL=http://localhost:3007
DEVICE_BOOTSTRAP_SECRET=replace-me
MAX_UPLOAD_SIZE_BYTES=524288000
ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-account-access-token-secret
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900
PADDLE_ENVIRONMENT=sandbox
PADDLE_API_KEY=replace-with-paddle-api-key
PADDLE_WEBHOOK_SECRET=replace-with-paddle-webhook-secret
PADDLE_PRO_MONTHLY_PRICE_ID=replace-with-paddle-monthly-price-id
PADDLE_PRO_YEARLY_PRICE_ID=replace-with-paddle-yearly-price-id

View File

@ -20,9 +20,5 @@ COPY --from=builder /app/backend/node_modules ./node_modules
COPY --from=builder /app/backend/dist ./dist
COPY --from=builder /app/backend/prisma ./prisma
WORKDIR /app
COPY runtime ./runtime
WORKDIR /app/backend
EXPOSE 3000
EXPOSE 3007
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]

View File

@ -1,6 +1,77 @@
{
"openapi": "3.0.0",
"paths": {
"/api/v1/billing/webhook": {
"post": {
"description": "Stores and deduplicates raw Paddle webhook events. Signature verification is not implemented in milestone 12.2.1 and is a known limitation until milestone 12.2.2.",
"operationId": "BillingController_ingestWebhook_v1",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BillingWebhookResponseDto"
}
}
}
},
"400": {
"description": "Invalid webhook payload"
}
},
"summary": "Receive Paddle webhook events",
"tags": [
"billing"
]
}
},
"/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 +577,57 @@
"scheme": "bearer",
"bearerFormat": "Bearer",
"type": "http",
"description": "Device access token"
"description": "Device access token or account access token"
}
},
"schemas": {
"BillingWebhookResponseDto": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
}
},
"required": [
"ok"
]
},
"CreateBillingCheckoutRequestDto": {
"type": "object",
"properties": {
"plan": {
"type": "string",
"enum": [
"PRO_MONTHLY",
"PRO_YEARLY"
]
}
},
"required": [
"plan"
]
},
"CreateBillingCheckoutResponseDto": {
"type": "object",
"properties": {
"plan": {
"type": "string",
"enum": [
"PRO_MONTHLY",
"PRO_YEARLY"
]
},
"checkoutUrl": {
"type": "string",
"example": "https://sandbox-checkout.paddle.com/checkout/txn_123"
}
},
"required": [
"plan",
"checkoutUrl"
]
},
"CurrentAccountResponseDto": {
"type": "object",
"properties": {

View File

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

View File

@ -0,0 +1,23 @@
-- CreateEnum
CREATE TYPE "BillingWebhookStatus" AS ENUM ('RECEIVED', 'DUPLICATE', 'FAILED');
-- CreateTable
CREATE TABLE "billing_webhook_events" (
"id" UUID NOT NULL,
"provider" "BillingProvider" NOT NULL,
"provider_event_id" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processed_at" TIMESTAMP(3),
"status" "BillingWebhookStatus" NOT NULL,
"error_message" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_webhook_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "billing_webhook_events_provider_provider_event_id_key"
ON "billing_webhook_events"("provider", "provider_event_id");

View File

@ -34,6 +34,7 @@ model User {
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
accountSessions AccountSession[]
billingCustomer BillingCustomer?
subscription UserSubscription?
entitlements UserEntitlement[]
@ -304,11 +305,22 @@ model AccountSession {
@@map("account_sessions")
}
model BillingCustomer {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
provider BillingProvider
providerCustomerId String @unique @map("provider_customer_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@map("billing_customers")
}
model UserSubscription {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
provider BillingProvider
providerCustomerId String? @unique @map("provider_customer_id")
providerSubscriptionId String? @unique @map("provider_subscription_id")
plan SubscriptionPlan
status SubscriptionStatus
@ -337,6 +349,23 @@ model UserEntitlement {
@@map("user_entitlements")
}
model BillingWebhookEvent {
id String @id @default(uuid()) @db.Uuid
provider BillingProvider
providerEventId String @map("provider_event_id")
eventType String @map("event_type")
payload Json
receivedAt DateTime @default(now()) @map("received_at")
processedAt DateTime? @map("processed_at")
status BillingWebhookStatus
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([provider, providerEventId])
@@map("billing_webhook_events")
}
enum SubscriptionStatus {
TRIAL
ACTIVE
@ -354,6 +383,12 @@ enum BillingProvider {
PADDLE
}
enum BillingWebhookStatus {
RECEIVED
DUPLICATE
FAILED
}
enum UserAccountKind {
LEGACY_DEFAULT
GUEST

View File

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

View File

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

View File

@ -41,6 +41,6 @@ export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(RequestContextMiddleware, ProtectedDeviceAuthMiddleware)
.forRoutes('*');
.forRoutes('{*path}');
}
}

View File

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

View File

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

View File

@ -0,0 +1,67 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiBadGatewayResponse,
ApiBadRequestResponse,
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
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 {
BillingWebhookResponseDto,
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('webhook')
@ApiOperation({
summary: 'Receive Paddle webhook events',
description:
'Stores and deduplicates raw Paddle webhook events. Signature verification is not implemented in milestone 12.2.1 and is a known limitation until milestone 12.2.2.',
})
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: BillingWebhookResponseDto })
@ApiBadRequestResponse({ description: 'Invalid webhook payload' })
async ingestWebhook(
@Body() payload: Record<string, unknown>,
): Promise<BillingWebhookResponseDto> {
return this.billingService.ingestPaddleWebhook(payload);
}
@Post('checkout')
@UseGuards(AccountAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: CreateBillingCheckoutResponseDto })
@ApiUnauthorizedResponse({ description: 'Account authentication required' })
@ApiBadRequestResponse({ description: 'Invalid checkout request' })
@ApiBadGatewayResponse({
description: 'Billing provider customer or checkout creation failed',
})
async createCheckout(
@CurrentAccount() account: AccountAuthContext,
@Body() body: CreateBillingCheckoutRequestDto,
): Promise<CreateBillingCheckoutResponseDto> {
return this.billingService.createCheckout(account, body.plan);
}
}

View File

@ -0,0 +1,32 @@
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;
}
export class BillingWebhookResponseDto {
@ApiProperty({ example: true })
ok!: true;
}

View File

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

View File

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

View File

@ -0,0 +1,430 @@
import {
BadGatewayException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import {
BillingProvider as BillingProviderKind,
BillingWebhookStatus,
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 billingWebhookEventFindUnique = jest.fn();
const billingWebhookEventCreate = jest.fn();
const billingWebhookEventUpdate = jest.fn();
const userSubscriptionUpsert = jest.fn();
const userEntitlementCreateMany = jest.fn();
const prismaService = {
user: {
findUnique: userFindUnique,
},
billingCustomer: {
create: billingCustomerCreate,
},
billingWebhookEvent: {
findUnique: billingWebhookEventFindUnique,
create: billingWebhookEventCreate,
update: billingWebhookEventUpdate,
},
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,
billingWebhookEventFindUnique,
billingWebhookEventCreate,
billingWebhookEventUpdate,
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();
});
it('stores a valid webhook event', async () => {
const { service, mocks } = buildService();
const payload = {
event_id: 'evt_123',
event_type: 'transaction.completed',
data: {
id: 'txn_123',
},
};
mocks.billingWebhookEventFindUnique.mockResolvedValue(null);
await expect(service.ingestPaddleWebhook(payload)).resolves.toEqual({
ok: true,
});
expect(mocks.billingWebhookEventCreate).toHaveBeenCalledWith({
data: {
provider: BillingProviderKind.PADDLE,
providerEventId: 'evt_123',
eventType: 'transaction.completed',
payload,
status: BillingWebhookStatus.RECEIVED,
},
});
});
it('does not store a duplicate webhook twice', async () => {
const { service, mocks } = buildService();
mocks.billingWebhookEventFindUnique.mockResolvedValue({
id: 'webhook-1',
});
await expect(
service.ingestPaddleWebhook({
event_id: 'evt_duplicate',
event_type: 'subscription.updated',
}),
).resolves.toEqual({
ok: true,
});
expect(mocks.billingWebhookEventCreate).not.toHaveBeenCalled();
expect(mocks.billingWebhookEventUpdate).toHaveBeenCalledWith({
where: {
id: 'webhook-1',
},
data: {
status: BillingWebhookStatus.DUPLICATE,
},
});
});
it('rejects missing webhook event ID', async () => {
const { service, mocks } = buildService();
await expect(
service.ingestPaddleWebhook({
event_type: 'transaction.completed',
}),
).rejects.toThrow(
new BadRequestException('Webhook payload event_id is required'),
);
expect(mocks.billingWebhookEventCreate).not.toHaveBeenCalled();
});
it('rejects missing webhook event type', async () => {
const { service, mocks } = buildService();
await expect(
service.ingestPaddleWebhook({
event_id: 'evt_missing_type',
}),
).rejects.toThrow(
new BadRequestException('Webhook payload event_type is required'),
);
expect(mocks.billingWebhookEventCreate).not.toHaveBeenCalled();
});
it('stores unknown webhook event types without processing', async () => {
const { service, mocks } = buildService();
const payload = {
event_id: 'evt_unknown',
event_type: 'reporting.exported',
};
mocks.billingWebhookEventFindUnique.mockResolvedValue(null);
await expect(service.ingestPaddleWebhook(payload)).resolves.toEqual({
ok: true,
});
expect(mocks.billingWebhookEventCreate).toHaveBeenCalledWith({
data: {
provider: BillingProviderKind.PADDLE,
providerEventId: 'evt_unknown',
eventType: 'reporting.exported',
payload,
status: BillingWebhookStatus.RECEIVED,
},
});
});
it('stores the webhook payload as JSON and does not write subscriptions or entitlements', async () => {
const { service, mocks } = buildService();
const payload = {
event_id: 'evt_payload',
event_type: 'subscription.created',
data: {
items: [{ price_id: 'pri_123' }],
custom_data: {
velodyAccountId: 'user-1',
},
},
};
mocks.billingWebhookEventFindUnique.mockResolvedValue(null);
await service.ingestPaddleWebhook(payload);
expect(mocks.billingWebhookEventCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
payload,
}),
});
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
expect(mocks.userEntitlementCreateMany).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,260 @@
import {
BadGatewayException,
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
BillingProvider as BillingProviderKind,
BillingWebhookStatus,
Prisma,
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 {
BillingWebhookResponseDto,
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;
}
interface PaddleWebhookPayload {
event_id?: unknown;
event_type?: unknown;
[key: string]: unknown;
}
const PADDLE_INGESTION_EVENT_TYPES = new Set([
'transaction.completed',
'subscription.created',
'subscription.updated',
'subscription.canceled',
]);
@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');
}
}
async ingestPaddleWebhook(
payload: Record<string, unknown>,
): Promise<BillingWebhookResponseDto> {
const { eventId, eventType } = this.extractWebhookMetadata(payload);
const existingEvent =
await this.prismaService.billingWebhookEvent.findUnique({
where: {
provider_providerEventId: {
provider: BillingProviderKind.PADDLE,
providerEventId: eventId,
},
},
select: {
id: true,
},
});
if (existingEvent) {
await this.prismaService.billingWebhookEvent.update({
where: {
id: existingEvent.id,
},
data: {
status: BillingWebhookStatus.DUPLICATE,
},
});
return { ok: true };
}
await this.prismaService.billingWebhookEvent.create({
data: {
provider: BillingProviderKind.PADDLE,
providerEventId: eventId,
eventType,
payload: payload as Prisma.InputJsonValue,
status: BillingWebhookStatus.RECEIVED,
},
});
if (!PADDLE_INGESTION_EVENT_TYPES.has(eventType)) {
return { ok: true };
}
return { ok: true };
}
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();
}
}
private extractWebhookMetadata(payload: PaddleWebhookPayload): {
eventId: string;
eventType: string;
} {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new BadRequestException('Webhook payload must be a JSON object');
}
const eventId = this.getRequiredString(payload.event_id, 'event_id');
const eventType = this.getRequiredString(payload.event_type, 'event_type');
return {
eventId,
eventType,
};
}
private getRequiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException(`Webhook payload ${field} is required`);
}
return value;
}
}

View File

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

View File

@ -71,8 +71,6 @@ export class LocalFilesystemStorageService {
join(this.root, 'temp', 'uploads'),
join(this.root, 'incoming'),
join(this.root, 'quarantine'),
join(this.root, 'library', 'audio'),
join(this.root, 'library', 'artwork'),
];
for (const path of paths) {

View File

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

View File

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

View File

@ -4,12 +4,17 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { Readable } from 'node:stream';
import {
BadRequestException,
BadGatewayException,
ExecutionContext,
ForbiddenException,
HttpStatus,
NotFoundException,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
@ -17,8 +22,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 +103,11 @@ 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 billingWebhookEvents = 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 +160,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 +219,7 @@ function createPrismaMock() {
[...users.values()].find((user) => user.slug === where.slug) ??
null;
return applySelect(matchingUser, select);
return attachRelations(matchingUser);
}
return null;
@ -187,6 +254,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 +326,129 @@ 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);
}),
},
billingWebhookEvent: {
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
const composite = where.provider_providerEventId;
const record =
[...billingWebhookEvents.values()].find(
(event) =>
event.provider === composite?.provider &&
event.providerEventId === composite?.providerEventId,
) ?? null;
return applySelect(record, select);
}),
create: jest.fn().mockImplementation(async ({ data }) => {
const now = new Date();
const record = {
id: randomUUID(),
receivedAt: now,
processedAt: null,
errorMessage: null,
createdAt: now,
updatedAt: now,
...data,
};
billingWebhookEvents.set(record.id, record);
return record;
}),
update: jest.fn().mockImplementation(async ({ where, data }) => {
const current = billingWebhookEvents.get(where.id);
const updated = {
...current,
...data,
updatedAt: new Date(),
};
billingWebhookEvents.set(where.id, updated);
return updated;
}),
},
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 +672,11 @@ function createPrismaMock() {
artworkAssets,
uploadSessions,
libraryEvents,
billingCustomers,
billingWebhookEvents,
userSubscriptions,
userOAuthIdentities,
userEntitlements,
},
};
}
@ -458,6 +687,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 +696,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 +766,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 +821,13 @@ 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'),
getPaddleWebhookSecret: jest.fn().mockReturnValue('paddle-webhook-secret'),
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
})
.overrideProvider(PrismaService)
.useValue(prismaMock)
@ -574,6 +853,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 +862,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 +1988,320 @@ 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('accepts a valid webhook event without account auth', async () => {
const payload = {
event_id: 'evt_e2e_valid',
event_type: 'transaction.completed',
data: {
id: 'txn_123',
},
};
expect(
Reflect.getMetadata(
HTTP_CODE_METADATA,
BillingController.prototype.ingestWebhook,
),
).toBe(HttpStatus.OK);
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
ok: true,
});
expect(prismaState.billingWebhookEvents.size).toBe(1);
expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({
provider: 'PADDLE',
providerEventId: 'evt_e2e_valid',
eventType: 'transaction.completed',
payload,
status: 'RECEIVED',
});
expect(prismaState.userSubscriptions.size).toBe(0);
expect(prismaState.userEntitlements.size).toBe(0);
});
it('returns ok for duplicate webhook events and does not store them twice', async () => {
const payload = {
event_id: 'evt_e2e_duplicate',
event_type: 'subscription.updated',
};
expect(
Reflect.getMetadata(
HTTP_CODE_METADATA,
BillingController.prototype.ingestWebhook,
),
).toBe(HttpStatus.OK);
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
ok: true,
});
await expect(billingController.ingestWebhook(payload)).resolves.toEqual({
ok: true,
});
expect(prismaState.billingWebhookEvents.size).toBe(1);
expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({
providerEventId: 'evt_e2e_duplicate',
status: 'DUPLICATE',
});
});
it('returns 400 for invalid webhook payloads', async () => {
await expect(
billingController.ingestWebhook({
event_type: 'subscription.created',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('keeps checkout protected while webhook stays public', async () => {
await expect(
billingController.ingestWebhook({
event_id: 'evt_public',
event_type: 'subscription.canceled',
}),
).resolves.toEqual({ ok: true });
await expect(
runBillingCheckoutRequest(undefined, {
plan: BillingCheckoutPlan.PRO_MONTHLY,
}),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
const ownerDevice = await devicesController.register({
platform: 'IPHONE',