Add Paddle webhook ingestion foundation
This commit is contained in:
parent
db83d3612d
commit
6498b3b38b
@ -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": {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -25,3 +25,8 @@ export class CreateBillingCheckoutResponseDto {
|
||||
})
|
||||
checkoutUrl!: string;
|
||||
}
|
||||
|
||||
export class BillingWebhookResponseDto {
|
||||
@ApiProperty({ example: true })
|
||||
ok!: true;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user