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
+125
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',