From f575e2395d228e91688e858c87d72c698ed20e2d Mon Sep 17 00:00:00 2001 From: sirily Date: Tue, 10 Mar 2026 16:22:16 +0300 Subject: [PATCH] fix: make invoice payment activation idempotent --- apps/web/src/main.ts | 26 ++- packages/db/package.json | 4 +- packages/db/src/billing-store.test.ts | 221 ++++++++++++++++++++++++++ packages/db/src/billing-store.ts | 93 +++++++++-- 4 files changed, 330 insertions(+), 14 deletions(-) create mode 100644 packages/db/src/billing-store.test.ts diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 2fa1422..69aefe3 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -5,6 +5,7 @@ import { } from "node:http"; import { loadConfig } from "@nproxy/config"; import { + BillingError, createPrismaAccountStore, createPrismaAuthStore, createPrismaBillingStore, @@ -211,7 +212,13 @@ const server = createServer(async (request, response) => { } const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? ""); - const invoice = await billingStore.markInvoicePaid({ invoiceId }); + const invoice = await billingStore.markInvoicePaid({ + invoiceId, + actor: { + type: "web_admin", + ref: authenticatedSession.user.id, + }, + }); sendJson(response, 200, { invoice: serializeBillingInvoice(invoice), }); @@ -672,6 +679,23 @@ function handleRequestError( return; } + if (error instanceof BillingError) { + const statusCode = + error.code === "invoice_not_found" + ? 404 + : error.code === "invoice_transition_not_allowed" + ? 409 + : 400; + + sendJson(response, statusCode, { + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + if (error instanceof GenerationRequestError) { const statusCode = error.code === "missing_active_subscription" diff --git a/packages/db/package.json b/packages/db/package.json index 3ee5f7c..9b93882 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,9 @@ "db:push": "prisma db push", "generate": "prisma generate", "migrate:deploy": "prisma migrate deploy", - "format": "prisma format" + "format": "prisma format", + "pretest": "pnpm build", + "test": "node --test dist/**/*.test.js" }, "dependencies": { "@nproxy/domain": "workspace:*", diff --git a/packages/db/src/billing-store.test.ts b/packages/db/src/billing-store.test.ts new file mode 100644 index 0000000..8ed73b8 --- /dev/null +++ b/packages/db/src/billing-store.test.ts @@ -0,0 +1,221 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Prisma } from "@prisma/client"; +import { BillingError, createPrismaBillingStore } from "./billing-store.js"; + +test("markInvoicePaid activates a pending invoice once and writes an admin audit log", async () => { + const invoice = createInvoiceFixture({ + status: "pending", + paidAt: null, + subscription: createSubscriptionFixture(), + }); + const database = createBillingDatabase({ + invoice, + }); + + const store = createPrismaBillingStore(database.client); + const result = await store.markInvoicePaid({ + invoiceId: invoice.id, + actor: { + type: "web_admin", + ref: "admin_user_1", + }, + }); + + assert.equal(result.status, "paid"); + assert.ok(result.paidAt instanceof Date); + assert.equal(database.calls.paymentInvoiceUpdate.length, 1); + assert.equal(database.calls.subscriptionUpdate.length, 1); + assert.equal(database.calls.usageLedgerCreate.length, 1); + assert.equal(database.calls.adminAuditCreate.length, 1); + + const paymentUpdate = database.calls.paymentInvoiceUpdate[0] as ({ + data: { status: "paid"; paidAt: Date }; + } | undefined); + const auditEntry = database.calls.adminAuditCreate[0]; + assert.ok(paymentUpdate); + assert.ok(auditEntry); + assert.equal(paymentUpdate.data.status, "paid"); + assert.equal(result.paidAt?.toISOString(), paymentUpdate.data.paidAt.toISOString()); + assert.equal(auditEntry.actorType, "web_admin"); + assert.equal(auditEntry.actorRef, "admin_user_1"); + assert.equal(auditEntry.action, "invoice_mark_paid"); + assert.equal(auditEntry.targetType, "payment_invoice"); + assert.equal(auditEntry.targetId, invoice.id); + assert.deepEqual(auditEntry.metadata, { + invoiceId: invoice.id, + subscriptionId: invoice.subscriptionId, + provider: invoice.provider, + providerInvoiceId: invoice.providerInvoiceId, + status: "paid", + paidAt: paymentUpdate.data.paidAt.toISOString(), + replayed: false, + }); +}); + +test("markInvoicePaid is idempotent for already paid invoices", async () => { + const paidAt = new Date("2026-03-10T12:00:00.000Z"); + const invoice = createInvoiceFixture({ + status: "paid", + paidAt, + subscription: createSubscriptionFixture(), + }); + const database = createBillingDatabase({ + invoice, + }); + + const store = createPrismaBillingStore(database.client); + const result = await store.markInvoicePaid({ + invoiceId: invoice.id, + actor: { + type: "web_admin", + ref: "admin_user_1", + }, + }); + + assert.equal(result.status, "paid"); + assert.equal(result.paidAt?.toISOString(), paidAt.toISOString()); + assert.equal(database.calls.paymentInvoiceUpdate.length, 0); + assert.equal(database.calls.subscriptionUpdate.length, 0); + assert.equal(database.calls.usageLedgerCreate.length, 0); + assert.equal(database.calls.adminAuditCreate.length, 1); + assert.equal(database.calls.adminAuditCreate[0]?.action, "invoice_mark_paid_replayed"); + assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true); +}); + +test("markInvoicePaid rejects invalid terminal invoice transitions", async () => { + const invoice = createInvoiceFixture({ + status: "expired", + paidAt: null, + subscription: createSubscriptionFixture(), + }); + const database = createBillingDatabase({ + invoice, + }); + const store = createPrismaBillingStore(database.client); + + await assert.rejects( + store.markInvoicePaid({ + invoiceId: invoice.id, + actor: { + type: "web_admin", + ref: "admin_user_1", + }, + }), + (error: unknown) => + error instanceof BillingError && + error.code === "invoice_transition_not_allowed" && + error.message === 'Invoice in status "expired" cannot be marked paid.', + ); + + assert.equal(database.calls.paymentInvoiceUpdate.length, 0); + assert.equal(database.calls.subscriptionUpdate.length, 0); + assert.equal(database.calls.usageLedgerCreate.length, 0); + assert.equal(database.calls.adminAuditCreate.length, 0); +}); + +function createBillingDatabase(input: { + invoice: ReturnType; +}) { + const calls = { + paymentInvoiceUpdate: [] as Array>, + subscriptionUpdate: [] as Array>, + usageLedgerCreate: [] as Array>, + adminAuditCreate: [] as Array>, + }; + + let currentInvoice = input.invoice; + + const transaction = { + paymentInvoice: { + findUnique: async () => currentInvoice, + update: async ({ data }: { data: { status: "paid"; paidAt: Date } }) => { + calls.paymentInvoiceUpdate.push({ data }); + currentInvoice = { + ...currentInvoice, + status: data.status, + paidAt: data.paidAt, + }; + return currentInvoice; + }, + }, + subscription: { + update: async ({ data }: { data: Record }) => { + calls.subscriptionUpdate.push({ data }); + return currentInvoice.subscription; + }, + }, + usageLedgerEntry: { + create: async ({ data }: { data: Record }) => { + calls.usageLedgerCreate.push({ data }); + return data; + }, + }, + adminAuditLog: { + create: async ({ data }: { data: Record }) => { + calls.adminAuditCreate.push(data); + return data; + }, + }, + }; + + const client = { + $transaction: async (callback: (tx: typeof transaction) => Promise) => callback(transaction), + } as unknown as Parameters[0]; + + return { + client, + calls, + }; +} + +function createInvoiceFixture(input: { + status: "pending" | "paid" | "expired" | "canceled"; + paidAt: Date | null; + subscription: ReturnType | null; +}) { + return { + id: "invoice_1", + userId: "user_1", + subscriptionId: input.subscription?.id ?? null, + provider: "nowpayments", + providerInvoiceId: "provider_invoice_1", + status: input.status, + currency: "USDT", + amountCrypto: new Prisma.Decimal("29"), + amountUsd: new Prisma.Decimal("29"), + paymentAddress: "wallet_1", + expiresAt: new Date("2026-03-11T12:00:00.000Z"), + paidAt: input.paidAt, + createdAt: new Date("2026-03-10T11:00:00.000Z"), + updatedAt: new Date("2026-03-10T11:00:00.000Z"), + subscription: input.subscription, + }; +} + +function createSubscriptionFixture() { + return { + id: "subscription_1", + userId: "user_1", + planId: "plan_1", + status: "pending_activation" as const, + renewsManually: true, + activatedAt: null, + currentPeriodStart: null, + currentPeriodEnd: null, + canceledAt: null, + createdAt: new Date("2026-03-10T11:00:00.000Z"), + updatedAt: new Date("2026-03-10T11:00:00.000Z"), + plan: { + id: "plan_1", + code: "basic", + displayName: "Basic", + monthlyRequestLimit: 100, + monthlyPriceUsd: new Prisma.Decimal("29"), + billingCurrency: "USDT", + isActive: true, + createdAt: new Date("2026-03-10T11:00:00.000Z"), + updatedAt: new Date("2026-03-10T11:00:00.000Z"), + }, + }; +} diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts index bfda003..5de59de 100644 --- a/packages/db/src/billing-store.ts +++ b/packages/db/src/billing-store.ts @@ -1,5 +1,11 @@ import type { PaymentProviderAdapter } from "@nproxy/providers"; -import { Prisma, type PaymentInvoiceStatus, type PrismaClient, type SubscriptionStatus } from "@prisma/client"; +import { + Prisma, + type AdminActorType, + type PaymentInvoiceStatus, + type PrismaClient, + type SubscriptionStatus, +} from "@prisma/client"; import { prisma as defaultPrisma } from "./prisma-client.js"; export interface BillingInvoiceRecord { @@ -36,6 +42,20 @@ export interface SubscriptionBillingRecord { }; } +export interface BillingActorMetadata { + type: AdminActorType; + ref?: string; +} + +export class BillingError extends Error { + constructor( + readonly code: "invoice_not_found" | "invoice_transition_not_allowed", + message: string, + ) { + super(message); + } +} + export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) { return { async listUserInvoices(userId: string): Promise { @@ -119,6 +139,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) async markInvoicePaid(input: { invoiceId: string; + actor?: BillingActorMetadata; }): Promise { return database.$transaction(async (transaction) => { const invoice = await transaction.paymentInvoice.findUnique({ @@ -133,20 +154,29 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) }); if (!invoice) { - throw new Error("Invoice not found."); + throw new BillingError("invoice_not_found", "Invoice not found."); + } + + if (invoice.status === "canceled" || invoice.status === "expired") { + throw new BillingError( + "invoice_transition_not_allowed", + `Invoice in status "${invoice.status}" cannot be marked paid.`, + ); + } + + if (invoice.status === "paid") { + await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true); + return mapInvoice(invoice); } const paidAt = invoice.paidAt ?? new Date(); - const updatedInvoice = - invoice.status === "paid" - ? invoice - : await transaction.paymentInvoice.update({ - where: { id: invoice.id }, - data: { - status: "paid", - paidAt, - }, - }); + const updatedInvoice = await transaction.paymentInvoice.update({ + where: { id: invoice.id }, + data: { + status: "paid", + paidAt, + }, + }); if (invoice.subscription) { const periodStart = paidAt; @@ -175,12 +205,51 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) }); } + await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false); + return mapInvoice(updatedInvoice); }); }, }; } +async function writeInvoicePaidAuditLog( + database: Pick, + invoice: { + id: string; + subscriptionId: string | null; + provider: string; + providerInvoiceId: string | null; + status: PaymentInvoiceStatus; + paidAt: Date | null; + }, + actor: BillingActorMetadata | undefined, + replayed: boolean, +): Promise { + if (!actor) { + return; + } + + await database.adminAuditLog.create({ + data: { + actorType: actor.type, + ...(actor.ref ? { actorRef: actor.ref } : {}), + action: replayed ? "invoice_mark_paid_replayed" : "invoice_mark_paid", + targetType: "payment_invoice", + targetId: invoice.id, + metadata: { + invoiceId: invoice.id, + subscriptionId: invoice.subscriptionId, + provider: invoice.provider, + providerInvoiceId: invoice.providerInvoiceId, + status: invoice.status, + paidAt: invoice.paidAt?.toISOString() ?? null, + replayed, + }, + }, + }); +} + function mapInvoice(invoice: { id: string; subscriptionId: string | null;