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/docs/ops/payment-system.md b/docs/ops/payment-system.md new file mode 100644 index 0000000..76a18e4 --- /dev/null +++ b/docs/ops/payment-system.md @@ -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` 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..2647b6a --- /dev/null +++ b/packages/db/src/billing-store.test.ts @@ -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; + updateManyCount?: number; + invoiceAfterFailedTransition?: ReturnType; +}) { + const calls = { + paymentInvoiceUpdateMany: [] as Array>, + subscriptionUpdate: [] as Array>, + usageLedgerCreate: [] as Array>, + adminAuditCreate: [] as Array>, + }; + + 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 }) => { + 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..723a31c 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,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, + 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;