Add Paddle webhook ingestion foundation

This commit is contained in:
diyaa 2026-07-11 08:40:31 +02:00
parent db83d3612d
commit 6498b3b38b
8 changed files with 477 additions and 2 deletions

View File

@ -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": {

View File

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

View File

@ -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

View File

@ -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<string, unknown>,
): Promise<BillingWebhookResponseDto> {
return this.billingService.ingestPaddleWebhook(payload);
}
@Post('checkout')
@UseGuards(AccountAuthGuard)
@ApiBearerAuth()

View File

@ -25,3 +25,8 @@ export class CreateBillingCheckoutResponseDto {
})
checkoutUrl!: string;
}
export class BillingWebhookResponseDto {
@ApiProperty({ example: true })
ok!: true;
}

View File

@ -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();
});
});

View File

@ -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<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> {
@ -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;
}
}

View File

@ -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<string, any>();
const libraryEvents = new Map<bigint, any>();
const billingCustomers = new Map<string, any>();
const billingWebhookEvents = new Map<string, any>();
const userSubscriptions = new Map<string, any>();
const userOAuthIdentities = new Map<string, any>();
const userEntitlements = new Map<string, any>();
@ -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',