Add Paddle webhook ingestion foundation
This commit is contained in:
parent
db83d3612d
commit
6498b3b38b
@ -1,6 +1,32 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
"/api/v1/billing/checkout": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "BillingController_createCheckout_v1",
|
"operationId": "BillingController_createCheckout_v1",
|
||||||
@ -555,6 +581,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"BillingWebhookResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"ok"
|
||||||
|
]
|
||||||
|
},
|
||||||
"CreateBillingCheckoutRequestDto": {
|
"CreateBillingCheckoutRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -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");
|
||||||
@ -349,6 +349,23 @@ 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
|
||||||
@ -366,6 +383,12 @@ enum BillingProvider {
|
|||||||
PADDLE
|
PADDLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BillingWebhookStatus {
|
||||||
|
RECEIVED
|
||||||
|
DUPLICATE
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
enum UserAccountKind {
|
enum UserAccountKind {
|
||||||
LEGACY_DEFAULT
|
LEGACY_DEFAULT
|
||||||
GUEST
|
GUEST
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBadGatewayResponse,
|
ApiBadGatewayResponse,
|
||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiUnauthorizedResponse,
|
ApiUnauthorizedResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
@ -11,6 +20,7 @@ import { AccountAuthGuard } from '../auth/account-auth.guard';
|
|||||||
import { CurrentAccount } from '../auth/current-account.decorator';
|
import { CurrentAccount } from '../auth/current-account.decorator';
|
||||||
import type { AccountAuthContext } from '../auth/account-auth-context';
|
import type { AccountAuthContext } from '../auth/account-auth-context';
|
||||||
import {
|
import {
|
||||||
|
BillingWebhookResponseDto,
|
||||||
CreateBillingCheckoutRequestDto,
|
CreateBillingCheckoutRequestDto,
|
||||||
CreateBillingCheckoutResponseDto,
|
CreateBillingCheckoutResponseDto,
|
||||||
} from './billing.dto';
|
} from './billing.dto';
|
||||||
@ -24,6 +34,21 @@ import { BillingService } from './billing.service';
|
|||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
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')
|
@Post('checkout')
|
||||||
@UseGuards(AccountAuthGuard)
|
@UseGuards(AccountAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -25,3 +25,8 @@ export class CreateBillingCheckoutResponseDto {
|
|||||||
})
|
})
|
||||||
checkoutUrl!: string;
|
checkoutUrl!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BillingWebhookResponseDto {
|
||||||
|
@ApiProperty({ example: true })
|
||||||
|
ok!: true;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { BillingCheckoutPlan } from './billing.dto';
|
||||||
import { BillingProviderService } from './billing-provider.service';
|
import { BillingProviderService } from './billing-provider.service';
|
||||||
import { BillingService } from './billing.service';
|
import { BillingService } from './billing.service';
|
||||||
@ -12,6 +16,9 @@ describe('BillingService', () => {
|
|||||||
function buildService() {
|
function buildService() {
|
||||||
const userFindUnique = jest.fn();
|
const userFindUnique = jest.fn();
|
||||||
const billingCustomerCreate = jest.fn();
|
const billingCustomerCreate = jest.fn();
|
||||||
|
const billingWebhookEventFindUnique = jest.fn();
|
||||||
|
const billingWebhookEventCreate = jest.fn();
|
||||||
|
const billingWebhookEventUpdate = jest.fn();
|
||||||
const userSubscriptionUpsert = jest.fn();
|
const userSubscriptionUpsert = jest.fn();
|
||||||
const userEntitlementCreateMany = jest.fn();
|
const userEntitlementCreateMany = jest.fn();
|
||||||
const prismaService = {
|
const prismaService = {
|
||||||
@ -21,6 +28,11 @@ describe('BillingService', () => {
|
|||||||
billingCustomer: {
|
billingCustomer: {
|
||||||
create: billingCustomerCreate,
|
create: billingCustomerCreate,
|
||||||
},
|
},
|
||||||
|
billingWebhookEvent: {
|
||||||
|
findUnique: billingWebhookEventFindUnique,
|
||||||
|
create: billingWebhookEventCreate,
|
||||||
|
update: billingWebhookEventUpdate,
|
||||||
|
},
|
||||||
userSubscription: {
|
userSubscription: {
|
||||||
upsert: userSubscriptionUpsert,
|
upsert: userSubscriptionUpsert,
|
||||||
},
|
},
|
||||||
@ -48,6 +60,9 @@ describe('BillingService', () => {
|
|||||||
configService,
|
configService,
|
||||||
mocks: {
|
mocks: {
|
||||||
billingCustomerCreate,
|
billingCustomerCreate,
|
||||||
|
billingWebhookEventFindUnique,
|
||||||
|
billingWebhookEventCreate,
|
||||||
|
billingWebhookEventUpdate,
|
||||||
userSubscriptionUpsert,
|
userSubscriptionUpsert,
|
||||||
userEntitlementCreateMany,
|
userEntitlementCreateMany,
|
||||||
},
|
},
|
||||||
@ -280,4 +295,136 @@ describe('BillingService', () => {
|
|||||||
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
|
expect(mocks.userSubscriptionUpsert).not.toHaveBeenCalled();
|
||||||
expect(mocks.userEntitlementCreateMany).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
BillingProvider as BillingProviderKind,
|
BillingProvider as BillingProviderKind,
|
||||||
|
BillingWebhookStatus,
|
||||||
|
Prisma,
|
||||||
UserAccountStatus,
|
UserAccountStatus,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
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 { AppConfigService } from '../config/config.service';
|
||||||
import { BillingProviderService } from './billing-provider.service';
|
import { BillingProviderService } from './billing-provider.service';
|
||||||
import {
|
import {
|
||||||
|
BillingWebhookResponseDto,
|
||||||
BillingCheckoutPlan,
|
BillingCheckoutPlan,
|
||||||
CreateBillingCheckoutResponseDto,
|
CreateBillingCheckoutResponseDto,
|
||||||
} from './billing.dto';
|
} from './billing.dto';
|
||||||
@ -30,6 +33,19 @@ interface CheckoutAccountRecord {
|
|||||||
} | 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()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(
|
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(
|
private async getCheckoutAccount(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<CheckoutAccountRecord> {
|
): Promise<CheckoutAccountRecord> {
|
||||||
@ -168,4 +232,29 @@ export class BillingService {
|
|||||||
return this.configService.getPaddleProYearlyPriceId();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,17 @@ 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,
|
BadGatewayException,
|
||||||
ExecutionContext,
|
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';
|
||||||
@ -101,6 +104,7 @@ function createPrismaMock() {
|
|||||||
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 billingCustomers = new Map<string, any>();
|
||||||
|
const billingWebhookEvents = new Map<string, any>();
|
||||||
const userSubscriptions = new Map<string, any>();
|
const userSubscriptions = new Map<string, any>();
|
||||||
const userOAuthIdentities = new Map<string, any>();
|
const userOAuthIdentities = new Map<string, any>();
|
||||||
const userEntitlements = new Map<string, any>();
|
const userEntitlements = new Map<string, any>();
|
||||||
@ -344,6 +348,43 @@ function createPrismaMock() {
|
|||||||
return applySelect(record, select);
|
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: {
|
userSubscription: {
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where, select }) => {
|
||||||
const record =
|
const record =
|
||||||
@ -632,6 +673,7 @@ function createPrismaMock() {
|
|||||||
uploadSessions,
|
uploadSessions,
|
||||||
libraryEvents,
|
libraryEvents,
|
||||||
billingCustomers,
|
billingCustomers,
|
||||||
|
billingWebhookEvents,
|
||||||
userSubscriptions,
|
userSubscriptions,
|
||||||
userOAuthIdentities,
|
userOAuthIdentities,
|
||||||
userEntitlements,
|
userEntitlements,
|
||||||
@ -783,6 +825,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
accountAccessTokenTtlSeconds: 900,
|
accountAccessTokenTtlSeconds: 900,
|
||||||
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
|
getPaddleEnvironment: jest.fn().mockReturnValue('sandbox'),
|
||||||
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
|
getPaddleApiKey: jest.fn().mockReturnValue('paddle-key'),
|
||||||
|
getPaddleWebhookSecret: jest.fn().mockReturnValue('paddle-webhook-secret'),
|
||||||
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
|
getPaddleProMonthlyPriceId: jest.fn().mockReturnValue('pri_monthly'),
|
||||||
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
|
getPaddleProYearlyPriceId: jest.fn().mockReturnValue('pri_yearly'),
|
||||||
})
|
})
|
||||||
@ -2177,6 +2220,88 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
fetchRequest.mockRestore();
|
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