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:
285
packages/db/src/billing-store.test.ts
Normal file
285
packages/db/src/billing-store.test.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user