Compare commits

..

No commits in common. "main" and "poadmap" have entirely different histories.

24 changed files with 64 additions and 1956 deletions

View File

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

View File

@ -5,12 +5,3 @@ STORAGE_ROOT=../runtime/storage
PUBLIC_BASE_URL=http://localhost:3007 PUBLIC_BASE_URL=http://localhost:3007
DEVICE_BOOTSTRAP_SECRET=replace-me DEVICE_BOOTSTRAP_SECRET=replace-me
MAX_UPLOAD_SIZE_BYTES=524288000 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,5 +20,9 @@ COPY --from=builder /app/backend/node_modules ./node_modules
COPY --from=builder /app/backend/dist ./dist COPY --from=builder /app/backend/dist ./dist
COPY --from=builder /app/backend/prisma ./prisma COPY --from=builder /app/backend/prisma ./prisma
EXPOSE 3007 WORKDIR /app
COPY runtime ./runtime
WORKDIR /app/backend
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"] CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]

View File

@ -1,77 +1,6 @@
{ {
"openapi": "3.0.0", "openapi": "3.0.0",
"paths": { "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": { "/api/v1/me": {
"get": { "get": {
"operationId": "AccountController_getMe_v1", "operationId": "AccountController_getMe_v1",
@ -577,57 +506,10 @@
"scheme": "bearer", "scheme": "bearer",
"bearerFormat": "Bearer", "bearerFormat": "Bearer",
"type": "http", "type": "http",
"description": "Device access token or account access token" "description": "Device access token"
} }
}, },
"schemas": { "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": { "CurrentAccountResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -1,45 +0,0 @@
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

@ -1,23 +0,0 @@
-- 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,7 +34,6 @@ model User {
incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser")
initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser") initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser")
accountSessions AccountSession[] accountSessions AccountSession[]
billingCustomer BillingCustomer?
subscription UserSubscription? subscription UserSubscription?
entitlements UserEntitlement[] entitlements UserEntitlement[]
@ -305,22 +304,11 @@ model AccountSession {
@@map("account_sessions") @@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 { model UserSubscription {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid userId String @unique @map("user_id") @db.Uuid
provider BillingProvider provider BillingProvider
providerCustomerId String? @unique @map("provider_customer_id")
providerSubscriptionId String? @unique @map("provider_subscription_id") providerSubscriptionId String? @unique @map("provider_subscription_id")
plan SubscriptionPlan plan SubscriptionPlan
status SubscriptionStatus status SubscriptionStatus
@ -349,23 +337,6 @@ model UserEntitlement {
@@map("user_entitlements") @@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 { enum SubscriptionStatus {
TRIAL TRIAL
ACTIVE ACTIVE
@ -383,12 +354,6 @@ enum BillingProvider {
PADDLE PADDLE
} }
enum BillingWebhookStatus {
RECEIVED
DUPLICATE
FAILED
}
enum UserAccountKind { enum UserAccountKind {
LEGACY_DEFAULT LEGACY_DEFAULT
GUEST GUEST

View File

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

View File

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

View File

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

View File

@ -1,35 +1,20 @@
export const BILLING_PROVIDER = Symbol('BILLING_PROVIDER'); export const BILLING_PROVIDER = Symbol('BILLING_PROVIDER');
export interface BillingCustomerInput { /**
email: string; * Provider-neutral contract for future billing integrations.
name: string; *
accountId: string; * 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 BillingCustomer { */
id: string;
}
export interface BillingCheckoutSessionInput {
customerId: string;
priceId: string;
accountId: string;
}
export interface BillingCheckoutSession {
url: string;
}
export interface BillingProvider { export interface BillingProvider {
createCustomer(input: BillingCustomerInput): Promise<BillingCustomer>; createCustomer(...args: unknown[]): Promise<unknown>;
createSubscription(...args: unknown[]): Promise<unknown>; createSubscription(...args: unknown[]): Promise<unknown>;
updateSubscription(...args: unknown[]): Promise<unknown>; updateSubscription(...args: unknown[]): Promise<unknown>;
cancelSubscription(...args: unknown[]): Promise<unknown>; cancelSubscription(...args: unknown[]): Promise<unknown>;
reactivateSubscription(...args: unknown[]): Promise<unknown>; reactivateSubscription(...args: unknown[]): Promise<unknown>;
getSubscription(...args: unknown[]): Promise<unknown>; getSubscription(...args: unknown[]): Promise<unknown>;
createCheckoutSession( createCheckoutSession(...args: unknown[]): Promise<unknown>;
input: BillingCheckoutSessionInput,
): Promise<BillingCheckoutSession>;
createCustomerPortalSession(...args: unknown[]): Promise<unknown>; createCustomerPortalSession(...args: unknown[]): Promise<unknown>;
validateWebhookSignature(...args: unknown[]): Promise<unknown>; validateWebhookSignature(...args: unknown[]): Promise<unknown>;
handleWebhookEvent(...args: unknown[]): Promise<unknown>; handleWebhookEvent(...args: unknown[]): Promise<unknown>;

View File

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

View File

@ -1,67 +0,0 @@
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

@ -1,32 +0,0 @@
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,11 +2,8 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { NotImplementedException } from '@nestjs/common'; import { NotImplementedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AppConfigService } from '../config/config.service';
import { import {
BILLING_PROVIDER, BILLING_PROVIDER,
BillingCheckoutSessionInput,
BillingCustomerInput,
BillingProvider, BillingProvider,
} from './billing-provider.interface'; } from './billing-provider.interface';
import { BillingProviderService } from './billing-provider.service'; import { BillingProviderService } from './billing-provider.service';
@ -14,11 +11,13 @@ import { BillingModule } from './billing.module';
import { PaddleBillingProvider } from './paddle-billing.provider'; import { PaddleBillingProvider } from './paddle-billing.provider';
const PROVIDER_METHODS = [ const PROVIDER_METHODS = [
'createCustomer',
'createSubscription', 'createSubscription',
'updateSubscription', 'updateSubscription',
'cancelSubscription', 'cancelSubscription',
'reactivateSubscription', 'reactivateSubscription',
'getSubscription', 'getSubscription',
'createCheckoutSession',
'createCustomerPortalSession', 'createCustomerPortalSession',
'validateWebhookSignature', 'validateWebhookSignature',
'handleWebhookEvent', 'handleWebhookEvent',
@ -32,13 +31,7 @@ describe('BillingModule', () => {
beforeAll(async () => { beforeAll(async () => {
moduleRef = await Test.createTestingModule({ moduleRef = await Test.createTestingModule({
imports: [BillingModule], imports: [BillingModule],
}) }).compile();
.overrideProvider(AppConfigService)
.useValue({
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
})
.compile();
provider = moduleRef.get(PaddleBillingProvider); provider = moduleRef.get(PaddleBillingProvider);
service = moduleRef.get(BillingProviderService); service = moduleRef.get(BillingProviderService);
}); });
@ -68,68 +61,6 @@ describe('BillingModule', () => {
expect(selectedProvider).toBe(provider); 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)( it.each(PROVIDER_METHODS)(
'throws NotImplementedException from provider method %s', 'throws NotImplementedException from provider method %s',
async (method) => { async (method) => {
@ -182,7 +113,7 @@ describe('BillingModule', () => {
); );
}); });
it('performs no HTTP requests while unimplemented operations reject', async () => { it('performs no HTTP requests while all operations reject', async () => {
const http = jest.requireActual<typeof import('node:http')>('node:http'); const http = jest.requireActual<typeof import('node:http')>('node:http');
const https = jest.requireActual<typeof import('node:https')>('node:https'); const https = jest.requireActual<typeof import('node:https')>('node:https');
const httpRequest = jest.spyOn(http, 'request'); const httpRequest = jest.spyOn(http, 'request');

View File

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

View File

@ -1,430 +0,0 @@
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

@ -1,260 +0,0 @@
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,61 +1,10 @@
import { import { Injectable, NotImplementedException } from '@nestjs/common';
Injectable, import { BillingProvider } from './billing-provider.interface';
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() @Injectable()
export class PaddleBillingProvider implements BillingProvider { export class PaddleBillingProvider implements BillingProvider {
constructor(private readonly configService: AppConfigService) {} async createCustomer(..._args: unknown[]): Promise<never> {
throw new NotImplementedException();
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> { async createSubscription(..._args: unknown[]): Promise<never> {
@ -78,40 +27,8 @@ export class PaddleBillingProvider implements BillingProvider {
throw new NotImplementedException(); throw new NotImplementedException();
} }
async createCheckoutSession( async createCheckoutSession(..._args: unknown[]): Promise<never> {
input: BillingCheckoutSessionInput, throw new NotImplementedException();
): 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> { async createCustomerPortalSession(..._args: unknown[]): Promise<never> {
@ -125,40 +42,4 @@ export class PaddleBillingProvider implements BillingProvider {
async handleWebhookEvent(..._args: unknown[]): Promise<never> { async handleWebhookEvent(..._args: unknown[]): Promise<never> {
throw new NotImplementedException(); 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,6 +71,8 @@ export class LocalFilesystemStorageService {
join(this.root, 'temp', 'uploads'), join(this.root, 'temp', 'uploads'),
join(this.root, 'incoming'), join(this.root, 'incoming'),
join(this.root, 'quarantine'), join(this.root, 'quarantine'),
join(this.root, 'library', 'audio'),
join(this.root, 'library', 'artwork'),
]; ];
for (const path of paths) { for (const path of paths) {

View File

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

View File

@ -4,17 +4,12 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { Readable } from 'node:stream'; import { Readable } from 'node:stream';
import { import {
BadRequestException,
BadGatewayException,
ExecutionContext,
ForbiddenException, ForbiddenException,
HttpStatus,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
ValidationPipe, ValidationPipe,
VersioningType, VersioningType,
} from '@nestjs/common'; } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { API_JSON_BODY_LIMIT } from '../../src/app.factory'; import { API_JSON_BODY_LIMIT } from '../../src/app.factory';
@ -22,15 +17,8 @@ import { AppModule } from '../../src/app.module';
import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service'; import { RequestContextService } from '../../src/infrastructure/request-context/request-context.service';
import { AssetsController } from '../../src/modules/assets/assets.controller'; import { AssetsController } from '../../src/modules/assets/assets.controller';
import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto'; 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 { DeviceAuthService } from '../../src/modules/auth/device-auth.service';
import { ArtworkController } from '../../src/modules/artwork/artwork.controller'; 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 { AppConfigService } from '../../src/modules/config/config.service';
import { DevicesController } from '../../src/modules/devices/devices.controller'; import { DevicesController } from '../../src/modules/devices/devices.controller';
import { HealthController } from '../../src/modules/health/health.controller'; import { HealthController } from '../../src/modules/health/health.controller';
@ -103,11 +91,6 @@ function createPrismaMock() {
const artworkAssets = new Map<string, any>(); const artworkAssets = new Map<string, any>();
const uploadSessions = new Map<string, any>(); const uploadSessions = new Map<string, any>();
const libraryEvents = new Map<bigint, 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; let nextLibraryEventId = 1n;
const createUserRecord = (data: Record<string, any>) => { const createUserRecord = (data: Record<string, any>) => {
@ -160,58 +143,8 @@ function createPrismaMock() {
return created; return created;
}), }),
findUnique: jest.fn().mockImplementation(async ({ where, select }) => { 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) { if (where.id) {
return attachRelations(users.get(where.id) ?? null); return applySelect(users.get(where.id) ?? null, select);
} }
if (where.slug) { if (where.slug) {
@ -219,7 +152,7 @@ function createPrismaMock() {
[...users.values()].find((user) => user.slug === where.slug) ?? [...users.values()].find((user) => user.slug === where.slug) ??
null; null;
return attachRelations(matchingUser); return applySelect(matchingUser, select);
} }
return null; return null;
@ -254,40 +187,6 @@ function createPrismaMock() {
return updated; 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: { device: {
create: jest.fn().mockImplementation(async ({ data }) => { create: jest.fn().mockImplementation(async ({ data }) => {
const record = { const record = {
@ -326,129 +225,6 @@ function createPrismaMock() {
return updated; 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: { track: {
findMany: jest.fn().mockImplementation(async ({ where }) => { findMany: jest.fn().mockImplementation(async ({ where }) => {
return [...tracks.values()] return [...tracks.values()]
@ -672,11 +448,6 @@ function createPrismaMock() {
artworkAssets, artworkAssets,
uploadSessions, uploadSessions,
libraryEvents, libraryEvents,
billingCustomers,
billingWebhookEvents,
userSubscriptions,
userOAuthIdentities,
userEntitlements,
}, },
}; };
} }
@ -687,7 +458,6 @@ describe('Velody API wiring (e2e)', () => {
let assetsController: AssetsController; let assetsController: AssetsController;
let accountController: AccountController; let accountController: AccountController;
let artworkController: ArtworkController; let artworkController: ArtworkController;
let billingController: BillingController;
let healthController: HealthController; let healthController: HealthController;
let devicesController: DevicesController; let devicesController: DevicesController;
let libraryController: LibraryController; let libraryController: LibraryController;
@ -696,8 +466,6 @@ describe('Velody API wiring (e2e)', () => {
let uploadsService: UploadsService; let uploadsService: UploadsService;
let requestContextService: RequestContextService; let requestContextService: RequestContextService;
let deviceAuthService: DeviceAuthService; let deviceAuthService: DeviceAuthService;
let authenticationRuntimeService: AuthenticationRuntimeService;
let accountAuthGuard: AccountAuthGuard;
let prismaState: ReturnType<typeof createPrismaMock>['state']; let prismaState: ReturnType<typeof createPrismaMock>['state'];
let storageRoot: string; let storageRoot: string;
@ -766,46 +534,6 @@ 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 () => { beforeEach(async () => {
const prismaSetup = createPrismaMock(); const prismaSetup = createPrismaMock();
prismaMock = prismaSetup.prismaMock; prismaMock = prismaSetup.prismaMock;
@ -821,13 +549,6 @@ describe('Velody API wiring (e2e)', () => {
appVersion: '0.1.0', appVersion: '0.1.0',
maxUploadSizeBytes: 1024 * 1024 * 1024, maxUploadSizeBytes: 1024 * 1024 * 1024,
storageRoot, 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) .overrideProvider(PrismaService)
.useValue(prismaMock) .useValue(prismaMock)
@ -853,7 +574,6 @@ describe('Velody API wiring (e2e)', () => {
assetsController = moduleRef.get(AssetsController); assetsController = moduleRef.get(AssetsController);
accountController = moduleRef.get(AccountController); accountController = moduleRef.get(AccountController);
artworkController = moduleRef.get(ArtworkController); artworkController = moduleRef.get(ArtworkController);
billingController = moduleRef.get(BillingController);
healthController = moduleRef.get(HealthController); healthController = moduleRef.get(HealthController);
devicesController = moduleRef.get(DevicesController); devicesController = moduleRef.get(DevicesController);
libraryController = moduleRef.get(LibraryController); libraryController = moduleRef.get(LibraryController);
@ -862,8 +582,6 @@ describe('Velody API wiring (e2e)', () => {
uploadsService = moduleRef.get(UploadsService); uploadsService = moduleRef.get(UploadsService);
requestContextService = moduleRef.get(RequestContextService); requestContextService = moduleRef.get(RequestContextService);
deviceAuthService = moduleRef.get(DeviceAuthService); deviceAuthService = moduleRef.get(DeviceAuthService);
authenticationRuntimeService = moduleRef.get(AuthenticationRuntimeService);
accountAuthGuard = moduleRef.get(AccountAuthGuard);
}); });
afterEach(async () => { afterEach(async () => {
@ -1988,320 +1706,6 @@ describe('Velody API wiring (e2e)', () => {
).rejects.toBeInstanceOf(UnauthorizedException); ).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 () => { it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => {
const ownerDevice = await devicesController.register({ const ownerDevice = await devicesController.register({
platform: 'IPHONE', platform: 'IPHONE',