fix: make invoice payment activation idempotent #18

Merged
sirily merged 4 commits from fix/invoice-payment-idempotency-audit into master 2026-03-10 17:53:01 +03:00
5 changed files with 628 additions and 13 deletions

View File

@@ -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"

190
docs/ops/payment-system.md Normal file
View File

@@ -0,0 +1,190 @@
# Payment System
## Purpose
Describe how billing and payment processing currently work in `nproxy`.
This document is about the implemented system, not the desired future state.
## Scope
The current payment system covers:
- default subscription plan bootstrap
- user subscription creation at registration time
- invoice creation through a provider adapter
- manual admin activation after an operator verifies that the provider reported a final successful payment status
- quota-cycle reset on successful activation
The current payment system does not yet cover:
- provider webhooks
- polling-based reconciliation
- automatic `expired` or `canceled` transitions
- recurring billing
## Main records
### `SubscriptionPlan`
- one default active plan is bootstrapped by `packages/db/src/bootstrap.ts`
- current default seed:
- `code`: `monthly`
- `displayName`: `Monthly`
- `monthlyRequestLimit`: `100`
- `monthlyPriceUsd`: `9.99`
- `billingCurrency`: `USDT`
### `Subscription`
- created for a new user during registration if the default plan exists
- starts in `pending_activation`
- becomes `active` only after successful invoice activation
- stores:
- `activatedAt`
- `currentPeriodStart`
- `currentPeriodEnd`
- `renewsManually`
### `PaymentInvoice`
- stores one provider-facing payment attempt for a subscription
- `pending` means the invoice exists, but the payment is not yet considered final or accepted
- `paid` means the payment is considered final and safe to activate against
- important fields:
- local `id`
- `subscriptionId`
- `provider`
- `providerInvoiceId`
- `status`
- `currency`
- `amountCrypto`
- `amountUsd`
- `paymentAddress`
- `expiresAt`
- `paidAt`
### `UsageLedgerEntry`
- records quota-affecting events
- on successful payment activation the system writes `cycle_reset`
- successful generations later write `generation_success`
### `AdminAuditLog`
- records state-changing admin actions
- the current admin `mark-paid` flow writes:
- `invoice_mark_paid`
- `invoice_mark_paid_replayed`
## Provider boundary
Payment-provider code stays behind `packages/providers/src/payments.ts`.
Current provider contract:
- `createInvoice(input) -> providerInvoiceId, paymentAddress, amountCrypto, amountUsd, currency, expiresAt`
Current runtime note:
- the provider adapter is still a placeholder adapter
- provider callbacks and status lookups are not implemented yet
- the rest of the payment flow is intentionally provider-agnostic
## Registration flow
1. User registers through `POST /api/auth/register`.
2. `packages/db/src/auth-store.ts` creates the user and session.
3. If the default plan exists, the store also creates a `Subscription` in `pending_activation`.
4. At this point the user has an account and a plan assignment, but not an active paid cycle.
## Invoice creation flow
1. Authenticated user calls `POST /api/billing/invoices`.
2. `packages/db/src/billing-store.ts` loads the latest subscription for that user.
3. If there is already a non-expired `pending` invoice for the same subscription, it is reused.
4. Otherwise the app calls the payment-provider adapter `createInvoice`.
5. The returned provider invoice data is persisted as a new local `PaymentInvoice` in `pending`.
6. The API returns invoice details, including provider invoice id, amount, address, and expiry time.
## Payment status semantics
- `pending` does not count as paid.
- `pending` does not activate the subscription.
- `pending` does not reset quota.
- The system must treat an invoice as `paid` only after the payment provider reports a final successful status, meaning the funds are accepted strongly enough for access activation.
- The current implementation does not fetch or verify that provider-final status automatically yet.
## Invoice listing flow
- `GET /api/billing/invoices` returns the user's invoices ordered by newest first.
- This is a read-only view over persisted `PaymentInvoice` rows.
## Current activation flow
The implemented activation path is manual and admin-driven.
1. An authenticated admin calls `POST /api/admin/invoices/:id/mark-paid`.
2. The web app resolves the admin session and passes actor metadata into the billing store.
3. This endpoint is intended to be used only after the operator has already verified that the provider reached a final successful payment state.
4. `markInvoicePaid` runs inside one database transaction.
5. If the invoice is `pending`, the store:
- updates the invoice to `paid`
- sets `paidAt`
- updates the related subscription to `active`
- sets `activatedAt` if it was not already set
- sets `currentPeriodStart = paidAt`
- sets `currentPeriodEnd = paidAt + 30 days`
- clears `canceledAt`
- writes a `UsageLedgerEntry` with `entryType = cycle_reset`
- writes an `AdminAuditLog` entry `invoice_mark_paid`
6. The API returns the updated invoice.
Important constraint:
- `mark-paid` is not evidence by itself that a `pending` invoice became payable.
- It is only the current manual mechanism for recording that the provider has already given final confirmation outside the app.
## Idempotency and transition rules
`markInvoicePaid` is replay-safe.
### Allowed cases
- `pending -> paid`
- `paid -> paid` as a no-op replay
### Replay behavior
If the invoice is already `paid`:
- the subscription is not mutated again
- no extra `cycle_reset` entry is created
- an audit event `invoice_mark_paid_replayed` is written
### Rejected cases
If the invoice is already terminal:
- `expired`
- `canceled`
the store rejects the request with `invoice_transition_not_allowed`.
If the invoice does not exist, the store returns `invoice_not_found`.
## How quota ties to payment
- A user can create generation requests only with an active subscription.
- Approximate quota shown to the user is derived from `generation_success` entries since the current billing cycle start.
- A successful payment activation starts a new cycle by writing `cycle_reset` and moving the subscription window forward.
- Failed generations do not consume quota.
## HTTP surface
- `POST /api/billing/invoices`
- create or reuse the current pending invoice for the authenticated user
- `GET /api/billing/invoices`
- list the authenticated user's invoices
- `POST /api/admin/invoices/:id/mark-paid`
- admin-only manual payment activation path
## Error behavior
Current payment-specific errors surfaced by the web app:
- `invoice_not_found` -> `404`
- `invoice_transition_not_allowed` -> `409`
## Current limitations
- The system still depends on manual admin confirmation to activate access.
- Because provider-final status is not ingested automatically yet, the app currently relies on operator judgment when calling `mark-paid`.
- No provider callback or reconciliation job updates invoice state automatically.
- No runtime path currently moves invoices to `expired` or `canceled`.
- The provider adapter does not yet verify external status or signatures.
- Subscription lifecycle beyond the current `mark-paid` path is still incomplete.
## Required future direction
- Add provider callbacks or polling-based reconciliation.
- Persist provider-final status before activating access automatically.
- Reduce or remove the need for operator judgment in the normal payment-success path.
## Code references
- `packages/db/src/bootstrap.ts`
- `packages/db/src/auth-store.ts`
- `packages/db/src/billing-store.ts`
- `packages/db/src/account-store.ts`
- `packages/providers/src/payments.ts`
- `apps/web/src/main.ts`

View File

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

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 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,74 @@ 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 transitionResult = await transaction.paymentInvoice.updateMany({
invoice.status === "paid" where: {
? invoice id: invoice.id,
: await transaction.paymentInvoice.update({ status: "pending",
where: { id: invoice.id }, },
data: { data: {
status: "paid", status: "paid",
paidAt, 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) { if (invoice.subscription) {
const periodStart = paidAt; const periodStart = paidAt;
@@ -175,12 +250,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;