fix: make invoice payment activation idempotent (#18)

Closes #2

## Summary
- make `markInvoicePaid` idempotent for already-paid invoices and reject invalid terminal transitions
- add admin actor metadata and audit-log writes for `mark-paid`, including replayed no-op calls
- add focused DB tests for first activation, replay safety, and invalid transition handling
- document the current payment system, including invoice creation, manual activation, quota reset, and current limitations

## Testing
- built `infra/docker/web.Dockerfile`
- ran `pnpm --filter @nproxy/db test` inside the built container
- verified `@nproxy/db build` and `@nproxy/web build` during the image build

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
2026-03-10 17:53:00 +03:00
parent 431a60f9c8
commit 1b2a4a076a
5 changed files with 628 additions and 13 deletions

View File

@@ -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:*",

View File

@@ -0,0 +1,285 @@
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.paymentInvoiceUpdateMany.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.paymentInvoiceUpdateMany[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.paymentInvoiceUpdateMany.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.paymentInvoiceUpdateMany.length, 0);
assert.equal(database.calls.subscriptionUpdate.length, 0);
assert.equal(database.calls.usageLedgerCreate.length, 0);
assert.equal(database.calls.adminAuditCreate.length, 0);
});
test("markInvoicePaid treats a concurrent pending->paid race as a replay without duplicate side effects", async () => {
const paidAt = new Date("2026-03-10T12:00:00.000Z");
const database = createBillingDatabase({
invoice: createInvoiceFixture({
status: "pending",
paidAt: null,
subscription: createSubscriptionFixture(),
}),
updateManyCount: 0,
invoiceAfterFailedTransition: createInvoiceFixture({
status: "paid",
paidAt,
subscription: createSubscriptionFixture(),
}),
});
const store = createPrismaBillingStore(database.client);
const result = await store.markInvoicePaid({
invoiceId: "invoice_1",
actor: {
type: "web_admin",
ref: "admin_user_1",
},
});
assert.equal(result.status, "paid");
assert.equal(result.paidAt?.toISOString(), paidAt.toISOString());
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
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);
});
function createBillingDatabase(input: {
invoice: ReturnType<typeof createInvoiceFixture>;
updateManyCount?: number;
invoiceAfterFailedTransition?: ReturnType<typeof createInvoiceFixture>;
}) {
const calls = {
paymentInvoiceUpdateMany: [] 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;
let findUniqueCallCount = 0;
const transaction = {
paymentInvoice: {
findUnique: async () => {
findUniqueCallCount += 1;
if (
input.invoiceAfterFailedTransition &&
input.updateManyCount === 0 &&
findUniqueCallCount > 1
) {
currentInvoice = input.invoiceAfterFailedTransition;
}
return currentInvoice;
},
updateMany: async ({
where,
data,
}: {
where: { id: string; status: "pending" };
data: { status: "paid"; paidAt: Date };
}) => {
calls.paymentInvoiceUpdateMany.push({ where, data });
const count =
input.updateManyCount ??
(currentInvoice.id === where.id && currentInvoice.status === where.status ? 1 : 0);
if (count > 0) {
currentInvoice = {
...currentInvoice,
status: data.status,
paidAt: data.paidAt,
};
}
return { count };
},
},
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"),
},
};
}

View File

@@ -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<BillingInvoiceRecord[]> {
@@ -119,6 +139,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
async markInvoicePaid(input: {
invoiceId: string;
actor?: BillingActorMetadata;
}): Promise<BillingInvoiceRecord> {
return database.$transaction(async (transaction) => {
const invoice = await transaction.paymentInvoice.findUnique({
@@ -133,20 +154,74 @@ 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 transitionResult = await transaction.paymentInvoice.updateMany({
where: {
id: invoice.id,
status: "pending",
},
data: {
status: "paid",
paidAt,
},
});
if (transitionResult.count === 0) {
const currentInvoice = await transaction.paymentInvoice.findUnique({
where: { id: input.invoiceId },
include: {
subscription: {
include: {
plan: true,
},
});
},
},
});
if (!currentInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (currentInvoice.status === "paid") {
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
return mapInvoice(currentInvoice);
}
throw new BillingError(
"invoice_transition_not_allowed",
`Invoice in status "${currentInvoice.status}" cannot be marked paid.`,
);
}
const updatedInvoice = await transaction.paymentInvoice.findUnique({
where: { id: invoice.id },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!updatedInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (invoice.subscription) {
const periodStart = paidAt;
@@ -175,12 +250,51 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
});
}
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
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: {
id: string;
subscriptionId: string | null;