diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 1e21523..b42472a 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -1,6 +1,32 @@ { "openapi": "3.0.0", "paths": { + "/api/v1/billing/webhook": { + "post": { + "description": "Stores and deduplicates raw Paddle webhook events. Signature verification is not implemented in milestone 12.2.1 and is a known limitation until milestone 12.2.2.", + "operationId": "BillingController_ingestWebhook_v1", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingWebhookResponseDto" + } + } + } + }, + "400": { + "description": "Invalid webhook payload" + } + }, + "summary": "Receive Paddle webhook events", + "tags": [ + "billing" + ] + } + }, "/api/v1/billing/checkout": { "post": { "operationId": "BillingController_createCheckout_v1", @@ -555,6 +581,18 @@ } }, "schemas": { + "BillingWebhookResponseDto": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "example": true + } + }, + "required": [ + "ok" + ] + }, "CreateBillingCheckoutRequestDto": { "type": "object", "properties": { diff --git a/backend/prisma/migrations/20260709110000_milestone1221_paddle_webhook_ingestion_foundation/migration.sql b/backend/prisma/migrations/20260709110000_milestone1221_paddle_webhook_ingestion_foundation/migration.sql new file mode 100644 index 0000000..acf1f40 --- /dev/null +++ b/backend/prisma/migrations/20260709110000_milestone1221_paddle_webhook_ingestion_foundation/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "BillingWebhookStatus" AS ENUM ('RECEIVED', 'DUPLICATE', 'FAILED'); + +-- CreateTable +CREATE TABLE "billing_webhook_events" ( + "id" UUID NOT NULL, + "provider" "BillingProvider" NOT NULL, + "provider_event_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "processed_at" TIMESTAMP(3), + "status" "BillingWebhookStatus" NOT NULL, + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "billing_webhook_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "billing_webhook_events_provider_provider_event_id_key" +ON "billing_webhook_events"("provider", "provider_event_id"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index eefde40..96fe881 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -349,6 +349,23 @@ model UserEntitlement { @@map("user_entitlements") } +model BillingWebhookEvent { + id String @id @default(uuid()) @db.Uuid + provider BillingProvider + providerEventId String @map("provider_event_id") + eventType String @map("event_type") + payload Json + receivedAt DateTime @default(now()) @map("received_at") + processedAt DateTime? @map("processed_at") + status BillingWebhookStatus + errorMessage String? @map("error_message") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([provider, providerEventId]) + @@map("billing_webhook_events") +} + enum SubscriptionStatus { TRIAL ACTIVE @@ -366,6 +383,12 @@ enum BillingProvider { PADDLE } +enum BillingWebhookStatus { + RECEIVED + DUPLICATE + FAILED +} + enum UserAccountKind { LEGACY_DEFAULT GUEST diff --git a/backend/src/modules/billing/billing.controller.ts b/backend/src/modules/billing/billing.controller.ts index f492cb7..d68d7aa 100644 --- a/backend/src/modules/billing/billing.controller.ts +++ b/backend/src/modules/billing/billing.controller.ts @@ -1,9 +1,18 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiBadGatewayResponse, ApiBadRequestResponse, ApiBearerAuth, ApiCreatedResponse, + ApiOkResponse, + ApiOperation, ApiTags, ApiUnauthorizedResponse, } from '@nestjs/swagger'; @@ -11,6 +20,7 @@ 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'; @@ -24,6 +34,21 @@ import { BillingService } from './billing.service'; 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, + ): Promise { + return this.billingService.ingestPaddleWebhook(payload); + } + @Post('checkout') @UseGuards(AccountAuthGuard) @ApiBearerAuth() diff --git a/backend/src/modules/billing/billing.dto.ts b/backend/src/modules/billing/billing.dto.ts index f764f9a..10e466d 100644 --- a/backend/src/modules/billing/billing.dto.ts +++ b/backend/src/modules/billing/billing.dto.ts @@ -25,3 +25,8 @@ export class CreateBillingCheckoutResponseDto { }) checkoutUrl!: string; } + +export class BillingWebhookResponseDto { + @ApiProperty({ example: true }) + ok!: true; +} diff --git a/backend/src/modules/billing/billing.service.spec.ts b/backend/src/modules/billing/billing.service.spec.ts index c4dd323..cf14c68 100644 --- a/backend/src/modules/billing/billing.service.spec.ts +++ b/backend/src/modules/billing/billing.service.spec.ts @@ -3,7 +3,11 @@ import { BadRequestException, NotFoundException, } from '@nestjs/common'; -import { BillingProvider as BillingProviderKind, UserAccountStatus } from '@prisma/client'; +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'; @@ -12,6 +16,9 @@ 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 = { @@ -21,6 +28,11 @@ describe('BillingService', () => { billingCustomer: { create: billingCustomerCreate, }, + billingWebhookEvent: { + findUnique: billingWebhookEventFindUnique, + create: billingWebhookEventCreate, + update: billingWebhookEventUpdate, + }, userSubscription: { upsert: userSubscriptionUpsert, }, @@ -48,6 +60,9 @@ describe('BillingService', () => { configService, mocks: { billingCustomerCreate, + billingWebhookEventFindUnique, + billingWebhookEventCreate, + billingWebhookEventUpdate, userSubscriptionUpsert, userEntitlementCreateMany, }, @@ -280,4 +295,136 @@ describe('BillingService', () => { 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(); + }); }); diff --git a/backend/src/modules/billing/billing.service.ts b/backend/src/modules/billing/billing.service.ts index b1e03ca..92e3524 100644 --- a/backend/src/modules/billing/billing.service.ts +++ b/backend/src/modules/billing/billing.service.ts @@ -6,6 +6,8 @@ import { } from '@nestjs/common'; import { BillingProvider as BillingProviderKind, + BillingWebhookStatus, + Prisma, UserAccountStatus, } from '@prisma/client'; import { PrismaService } from '../../infrastructure/database/prisma.service'; @@ -13,6 +15,7 @@ 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'; @@ -30,6 +33,19 @@ interface CheckoutAccountRecord { } | 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( @@ -67,6 +83,54 @@ export class BillingService { } } + async ingestPaddleWebhook( + payload: Record, + ): Promise { + 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 { @@ -168,4 +232,29 @@ export class BillingService { 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; + } } diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 6140539..68550f8 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -4,14 +4,17 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; import { + BadRequestException, BadGatewayException, ExecutionContext, ForbiddenException, + HttpStatus, NotFoundException, UnauthorizedException, ValidationPipe, VersioningType, } from '@nestjs/common'; +import { HTTP_CODE_METADATA } from '@nestjs/common/constants'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { Test } from '@nestjs/testing'; import { API_JSON_BODY_LIMIT } from '../../src/app.factory'; @@ -101,6 +104,7 @@ function createPrismaMock() { const uploadSessions = new Map(); const libraryEvents = new Map(); const billingCustomers = new Map(); + const billingWebhookEvents = new Map(); const userSubscriptions = new Map(); const userOAuthIdentities = new Map(); const userEntitlements = new Map(); @@ -344,6 +348,43 @@ function createPrismaMock() { 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 = @@ -632,6 +673,7 @@ function createPrismaMock() { uploadSessions, libraryEvents, billingCustomers, + billingWebhookEvents, userSubscriptions, userOAuthIdentities, userEntitlements, @@ -783,6 +825,7 @@ describe('Velody API wiring (e2e)', () => { 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'), }) @@ -2177,6 +2220,88 @@ describe('Velody API wiring (e2e)', () => { fetchRequest.mockRestore(); }); + it('accepts a valid webhook event without account auth', async () => { + const payload = { + event_id: 'evt_e2e_valid', + event_type: 'transaction.completed', + data: { + id: 'txn_123', + }, + }; + + expect( + Reflect.getMetadata( + HTTP_CODE_METADATA, + BillingController.prototype.ingestWebhook, + ), + ).toBe(HttpStatus.OK); + + await expect(billingController.ingestWebhook(payload)).resolves.toEqual({ + ok: true, + }); + + expect(prismaState.billingWebhookEvents.size).toBe(1); + expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({ + provider: 'PADDLE', + providerEventId: 'evt_e2e_valid', + eventType: 'transaction.completed', + payload, + status: 'RECEIVED', + }); + expect(prismaState.userSubscriptions.size).toBe(0); + expect(prismaState.userEntitlements.size).toBe(0); + }); + + it('returns ok for duplicate webhook events and does not store them twice', async () => { + const payload = { + event_id: 'evt_e2e_duplicate', + event_type: 'subscription.updated', + }; + + expect( + Reflect.getMetadata( + HTTP_CODE_METADATA, + BillingController.prototype.ingestWebhook, + ), + ).toBe(HttpStatus.OK); + + await expect(billingController.ingestWebhook(payload)).resolves.toEqual({ + ok: true, + }); + await expect(billingController.ingestWebhook(payload)).resolves.toEqual({ + ok: true, + }); + + expect(prismaState.billingWebhookEvents.size).toBe(1); + expect([...prismaState.billingWebhookEvents.values()][0]).toMatchObject({ + providerEventId: 'evt_e2e_duplicate', + status: 'DUPLICATE', + }); + }); + + it('returns 400 for invalid webhook payloads', async () => { + await expect( + billingController.ingestWebhook({ + event_type: 'subscription.created', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('keeps checkout protected while webhook stays public', async () => { + await expect( + billingController.ingestWebhook({ + event_id: 'evt_public', + event_type: 'subscription.canceled', + }), + ).resolves.toEqual({ ok: true }); + + await expect( + runBillingCheckoutRequest(undefined, { + plan: BillingCheckoutPlan.PRO_MONTHLY, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + it('rejects invalid or revoked device tokens even when a legacy device id is supplied', async () => { const ownerDevice = await devicesController.register({ platform: 'IPHONE',