Compare commits
No commits in common. "main" and "poadmap" have entirely different histories.
@ -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
|
|
||||||
42
README.md
42
README.md
@ -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`.
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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";
|
|
||||||
@ -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");
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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";`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user