fix: make invoice payment activation idempotent
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "node:http";
|
} from "node:http";
|
||||||
import { loadConfig } from "@nproxy/config";
|
import { loadConfig } from "@nproxy/config";
|
||||||
import {
|
import {
|
||||||
|
BillingError,
|
||||||
createPrismaAccountStore,
|
createPrismaAccountStore,
|
||||||
createPrismaAuthStore,
|
createPrismaAuthStore,
|
||||||
createPrismaBillingStore,
|
createPrismaBillingStore,
|
||||||
@@ -211,7 +212,13 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invoiceId = decodeURIComponent(invoiceMarkPaidMatch[1] ?? "");
|
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, {
|
sendJson(response, 200, {
|
||||||
invoice: serializeBillingInvoice(invoice),
|
invoice: serializeBillingInvoice(invoice),
|
||||||
});
|
});
|
||||||
@@ -672,6 +679,23 @@ function handleRequestError(
|
|||||||
return;
|
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) {
|
if (error instanceof GenerationRequestError) {
|
||||||
const statusCode =
|
const statusCode =
|
||||||
error.code === "missing_active_subscription"
|
error.code === "missing_active_subscription"
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"generate": "prisma generate",
|
"generate": "prisma generate",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"format": "prisma format"
|
"format": "prisma format",
|
||||||
|
"pretest": "pnpm build",
|
||||||
|
"test": "node --test dist/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nproxy/domain": "workspace:*",
|
"@nproxy/domain": "workspace:*",
|
||||||
|
|||||||
221
packages/db/src/billing-store.test.ts
Normal file
221
packages/db/src/billing-store.test.ts
Normal file
@@ -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<typeof createInvoiceFixture>;
|
||||||
|
}) {
|
||||||
|
const calls = {
|
||||||
|
paymentInvoiceUpdate: [] as Array<Record<string, unknown>>,
|
||||||
|
subscriptionUpdate: [] as Array<Record<string, unknown>>,
|
||||||
|
usageLedgerCreate: [] as Array<Record<string, unknown>>,
|
||||||
|
adminAuditCreate: [] as Array<Record<string, any>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown> }) => {
|
||||||
|
calls.subscriptionUpdate.push({ data });
|
||||||
|
return currentInvoice.subscription;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
usageLedgerEntry: {
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.usageLedgerCreate.push({ data });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminAuditLog: {
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
calls.adminAuditCreate.push(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
$transaction: async <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction),
|
||||||
|
} as unknown as Parameters<typeof createPrismaBillingStore>[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInvoiceFixture(input: {
|
||||||
|
status: "pending" | "paid" | "expired" | "canceled";
|
||||||
|
paidAt: Date | null;
|
||||||
|
subscription: ReturnType<typeof createSubscriptionFixture> | 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"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { PaymentProviderAdapter } from "@nproxy/providers";
|
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";
|
import { prisma as defaultPrisma } from "./prisma-client.js";
|
||||||
|
|
||||||
export interface BillingInvoiceRecord {
|
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) {
|
export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) {
|
||||||
return {
|
return {
|
||||||
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
|
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
|
||||||
@@ -119,6 +139,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
|
|
||||||
async markInvoicePaid(input: {
|
async markInvoicePaid(input: {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
|
actor?: BillingActorMetadata;
|
||||||
}): Promise<BillingInvoiceRecord> {
|
}): Promise<BillingInvoiceRecord> {
|
||||||
return database.$transaction(async (transaction) => {
|
return database.$transaction(async (transaction) => {
|
||||||
const invoice = await transaction.paymentInvoice.findUnique({
|
const invoice = await transaction.paymentInvoice.findUnique({
|
||||||
@@ -133,20 +154,29 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!invoice) {
|
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 paidAt = invoice.paidAt ?? new Date();
|
||||||
const updatedInvoice =
|
const updatedInvoice = await transaction.paymentInvoice.update({
|
||||||
invoice.status === "paid"
|
where: { id: invoice.id },
|
||||||
? invoice
|
data: {
|
||||||
: await transaction.paymentInvoice.update({
|
status: "paid",
|
||||||
where: { id: invoice.id },
|
paidAt,
|
||||||
data: {
|
},
|
||||||
status: "paid",
|
});
|
||||||
paidAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (invoice.subscription) {
|
if (invoice.subscription) {
|
||||||
const periodStart = paidAt;
|
const periodStart = paidAt;
|
||||||
@@ -175,12 +205,51 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
|
||||||
|
|
||||||
return mapInvoice(updatedInvoice);
|
return mapInvoice(updatedInvoice);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeInvoicePaidAuditLog(
|
||||||
|
database: Pick<PrismaClient, "adminAuditLog">,
|
||||||
|
invoice: {
|
||||||
|
id: string;
|
||||||
|
subscriptionId: string | null;
|
||||||
|
provider: string;
|
||||||
|
providerInvoiceId: string | null;
|
||||||
|
status: PaymentInvoiceStatus;
|
||||||
|
paidAt: Date | null;
|
||||||
|
},
|
||||||
|
actor: BillingActorMetadata | undefined,
|
||||||
|
replayed: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
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: {
|
function mapInvoice(invoice: {
|
||||||
id: string;
|
id: string;
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user