From 9641678fa34a493e47c4afef429dc727d681213d Mon Sep 17 00:00:00 2001 From: sirily Date: Wed, 11 Mar 2026 12:33:03 +0300 Subject: [PATCH] feat: add renewal invoice sweep (#20) Refs #9 ## Summary - add a worker-side renewal invoice sweep that creates one invoice 72 hours before subscription expiry - expire elapsed pending invoices automatically and email users when an automatic renewal invoice is created - stop auto-recreating invoices for the same paid cycle once any invoice already exists for that cycle - document the current renewal-invoice and pending-invoice expiry behavior ## 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 - built `infra/docker/worker.Dockerfile` Co-authored-by: sirily Reviewed-on: http://git.shararam.party/sirily/nroxy/pulls/20 --- apps/worker/src/main.ts | 155 +++++- docs/ops/payment-system.md | 75 ++- packages/db/src/billing-store.test.ts | 423 ++++++++++++++++- packages/db/src/billing-store.ts | 648 ++++++++++++++++++++------ packages/providers/src/payments.ts | 24 + 5 files changed, 1137 insertions(+), 188 deletions(-) diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 6777104..9269d69 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,20 +1,30 @@ import { loadConfig } from "@nproxy/config"; -import { createPrismaWorkerStore, prisma } from "@nproxy/db"; -import { createNanoBananaSimulatedAdapter } from "@nproxy/providers"; +import { createPrismaBillingStore, createPrismaWorkerStore, prisma } from "@nproxy/db"; +import { + createEmailTransport, + createNanoBananaSimulatedAdapter, + createPaymentProviderAdapter, +} from "@nproxy/providers"; const config = loadConfig(); const intervalMs = config.keyPool.balancePollSeconds * 1000; +const renewalLeadTimeHours = 72; +const invoiceReconciliationBatchSize = 100; const workerStore = createPrismaWorkerStore(prisma, { cooldownMinutes: config.keyPool.cooldownMinutes, failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview, }); +const billingStore = createPrismaBillingStore(prisma); const nanoBananaAdapter = createNanoBananaSimulatedAdapter(); +const paymentProviderAdapter = createPaymentProviderAdapter(config.payment); +const emailTransport = createEmailTransport(config.email); let isTickRunning = false; console.log( JSON.stringify({ service: "worker", balancePollSeconds: config.keyPool.balancePollSeconds, + renewalLeadTimeHours, providerModel: config.provider.nanoBananaDefaultModel, }), ); @@ -44,6 +54,147 @@ async function runTick(): Promise { isTickRunning = true; try { + const renewalNotifications = await billingStore.createUpcomingRenewalInvoices({ + paymentProvider: config.payment.provider, + paymentProviderAdapter, + renewalLeadTimeHours, + }); + + for (const notification of renewalNotifications) { + const billingUrl = new URL("/billing", config.urls.appBaseUrl); + await emailTransport.send({ + to: notification.email, + subject: "Your nproxy subscription renewal invoice", + text: [ + "Your current subscription period is ending soon.", + `Current access ends at ${notification.subscriptionCurrentPeriodEnd.toISOString()}.`, + `Invoice amount: ${notification.invoice.amountCrypto} ${notification.invoice.currency}.`, + ...(notification.invoice.paymentAddress + ? [`Payment address: ${notification.invoice.paymentAddress}.`] + : []), + ...(notification.invoice.expiresAt + ? [`Invoice expires at ${notification.invoice.expiresAt.toISOString()}.`] + : []), + `Open billing: ${billingUrl.toString()}`, + ].join("\n"), + }); + } + + if (renewalNotifications.length > 0) { + console.log( + JSON.stringify({ + service: "worker", + event: "renewal_invoices_created", + createdCount: renewalNotifications.length, + }), + ); + } + + const pendingInvoices = + await billingStore.listPendingInvoicesForReconciliation(invoiceReconciliationBatchSize); + const reconciliationSummary = { + polledCount: pendingInvoices.length, + markedPaidCount: 0, + markedExpiredCount: 0, + markedCanceledCount: 0, + alreadyTerminalCount: 0, + ignoredCount: 0, + failedCount: 0, + }; + + for (const invoice of pendingInvoices) { + try { + const providerInvoice = await paymentProviderAdapter.getInvoiceStatus(invoice.providerInvoiceId); + + if (providerInvoice.providerInvoiceId !== invoice.providerInvoiceId) { + reconciliationSummary.failedCount += 1; + console.error( + JSON.stringify({ + service: "worker", + event: "invoice_reconciliation_provider_mismatch", + invoiceId: invoice.id, + requestedProviderInvoiceId: invoice.providerInvoiceId, + returnedProviderInvoiceId: providerInvoice.providerInvoiceId, + }), + ); + continue; + } + + const result = await billingStore.reconcilePendingInvoice({ + invoiceId: invoice.id, + providerStatus: providerInvoice.status, + actor: { + type: "system", + ref: "invoice_reconciliation", + }, + ...(providerInvoice.paidAt ? { paidAt: providerInvoice.paidAt } : {}), + }); + + switch (result.outcome) { + case "marked_paid": + reconciliationSummary.markedPaidCount += 1; + break; + case "marked_expired": + reconciliationSummary.markedExpiredCount += 1; + break; + case "marked_canceled": + reconciliationSummary.markedCanceledCount += 1; + break; + case "already_paid": + case "already_expired": + case "already_canceled": + reconciliationSummary.alreadyTerminalCount += 1; + break; + case "ignored_terminal_state": + reconciliationSummary.ignoredCount += 1; + break; + case "noop_pending": + break; + } + } catch (error) { + reconciliationSummary.failedCount += 1; + console.error( + JSON.stringify({ + service: "worker", + event: "invoice_reconciliation_failed", + invoiceId: invoice.id, + providerInvoiceId: invoice.providerInvoiceId, + error: error instanceof Error ? error.message : String(error), + }), + ); + } + } + + if ( + reconciliationSummary.polledCount > 0 || + reconciliationSummary.failedCount > 0 || + reconciliationSummary.markedPaidCount > 0 || + reconciliationSummary.markedExpiredCount > 0 || + reconciliationSummary.markedCanceledCount > 0 || + reconciliationSummary.alreadyTerminalCount > 0 || + reconciliationSummary.ignoredCount > 0 + ) { + console.log( + JSON.stringify({ + service: "worker", + event: "pending_invoice_reconciliation", + ...reconciliationSummary, + }), + ); + } + + const expiredInvoices = await billingStore.expireElapsedPendingInvoices(); + + if (expiredInvoices.expiredCount > 0) { + console.log( + JSON.stringify({ + service: "worker", + event: "pending_invoices_expired", + expiredCount: expiredInvoices.expiredCount, + }), + ); + } + const recovery = await workerStore.recoverCooldownProviderKeys(); if (recovery.recoveredCount > 0) { diff --git a/docs/ops/payment-system.md b/docs/ops/payment-system.md index d4e147b..18db3bd 100644 --- a/docs/ops/payment-system.md +++ b/docs/ops/payment-system.md @@ -10,14 +10,17 @@ The current payment system covers: - default subscription plan bootstrap - user subscription creation at registration time - invoice creation through a provider adapter +- automatic renewal-invoice creation `72 hours` before `currentPeriodEnd` +- one renewal-invoice creation attempt per paid cycle unless the user explicitly creates a new one manually +- worker-side polling reconciliation for `pending` invoices - manual admin activation after an operator verifies that the provider reported a final successful payment status - automatic expiry of elapsed subscription periods during account and generation access checks +- automatic expiry of elapsed pending invoices +- automatic provider-driven `expired` and `canceled` transitions for pending invoices - 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 @@ -74,10 +77,12 @@ Payment-provider code stays behind `packages/providers/src/payments.ts`. Current provider contract: - `createInvoice(input) -> providerInvoiceId, paymentAddress, amountCrypto, amountUsd, currency, expiresAt` +- `getInvoiceStatus(providerInvoiceId) -> pending|paid|expired|canceled + paidAt? + expiresAt?` Current runtime note: - the provider adapter is still a placeholder adapter -- provider callbacks and status lookups are not implemented yet +- worker polling is implemented, but provider-specific HTTP/status mapping is still placeholder logic +- provider callbacks and webhook signature verification are not implemented yet - the rest of the payment flow is intentionally provider-agnostic ## Registration flow @@ -94,25 +99,52 @@ Current runtime note: 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. +## Automatic renewal invoice flow +1. The worker scans `active` manual-renewal subscriptions. +2. If `currentPeriodEnd` is within the next `72 hours`, the worker checks whether the current paid cycle already has an invoice. +3. If the current cycle has no invoice yet, the worker creates one renewal invoice through the payment-provider adapter. +4. The worker sends the invoice details to the user by email. +5. If any invoice already exists for the current cycle, the worker does not auto-create another one. + +Current rule: +- after the first invoice exists for the current paid cycle, automatic re-creation stops for that cycle +- if that invoice later expires or is canceled, the next invoice is created only when the user explicitly goes to billing and creates one + ## 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. +- Worker reconciliation now polls provider status for stored `pending` invoices. +- If the provider reports final `paid`, the worker activates access from provider `paidAt` when that timestamp is available. +- If the provider reports final `paid` after a local fallback expiry, provider `paid` still wins and access is activated. +- If the provider reports final `expired` or `canceled`, the worker finalizes the local invoice to the same terminal status. + +## Worker reconciliation flow +1. The worker loads a batch of `pending` invoices that have `providerInvoiceId`. +2. For each invoice, it calls `paymentProviderAdapter.getInvoiceStatus(providerInvoiceId)`. +3. If the provider still reports `pending`, the worker leaves the invoice unchanged. +4. If the provider reports `paid`, the worker calls the same idempotent activation path as admin `mark-paid`. +5. If the provider reports `expired` or `canceled`, the worker atomically moves the local invoice from `pending` to that terminal state. +6. After provider polling, the worker also expires any still-pending invoices whose local `expiresAt` has elapsed. +7. If a manual admin action or another worker already finalized the invoice, reconciliation degrades to replay/no-op behavior instead of duplicating side effects. + +Implementation note: +- invoice creation paths take a per-subscription database lock so manual invoice creation and worker renewal creation do not create duplicate invoices for the same subscription at the same time. ## 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. +- The worker also marks `pending` invoices `expired` when `expiresAt` has passed. ## Current activation flow -The implemented activation path is manual and admin-driven. +The implemented activation paths are: +- automatic worker reconciliation after provider final status +- manual admin override after an operator verifies provider final status outside the app -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: +Shared activation behavior: +- `markInvoicePaid` runs inside one database transaction. +- If the invoice is `pending`, the store: - updates the invoice to `paid` - sets `paidAt` - updates the related subscription to `active` @@ -121,8 +153,13 @@ The implemented activation path is manual and admin-driven. - 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. + - writes an `AdminAuditLog` entry `invoice_mark_paid` when actor metadata is present + +Manual admin path: +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. The API returns the updated invoice. Important constraint: - `mark-paid` is not evidence by itself that a `pending` invoice became payable. @@ -178,16 +215,14 @@ Current payment-specific errors surfaced by the web app: - `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 is still incomplete on the invoice side because provider-driven expiry, cancelation, and reconciliation are not implemented yet. +- The provider adapter still uses placeholder status lookups; real provider HTTP integration is not implemented yet. +- No provider callback or webhook signature verification path exists yet. +- Manual admin `mark-paid` still exists as an override, so operator judgment is still part of the system for exceptional cases. +- The worker polls invoice status in batches; there is no provider push path yet. ## Required future direction -- Add provider callbacks or polling-based reconciliation. -- Persist provider-final status before activating access automatically. +- Replace placeholder provider status lookups with real provider integration. +- Add provider callbacks or webhook ingestion on top of polling where the chosen provider supports it. - Reduce or remove the need for operator judgment in the normal payment-success path. ## Code references diff --git a/packages/db/src/billing-store.test.ts b/packages/db/src/billing-store.test.ts index 2647b6a..40d494b 100644 --- a/packages/db/src/billing-store.test.ts +++ b/packages/db/src/billing-store.test.ts @@ -3,6 +3,142 @@ import assert from "node:assert/strict"; import { Prisma } from "@prisma/client"; import { BillingError, createPrismaBillingStore } from "./billing-store.js"; +test("createUpcomingRenewalInvoices creates one invoice for subscriptions entering the 72h renewal window", async () => { + const database = createRenewalBillingDatabase({ + subscriptions: [ + createRenewalSubscriptionFixture({ + id: "subscription_1", + currentPeriodStart: new Date("2026-03-01T12:00:00.000Z"), + currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"), + }), + ], + }); + const store = createPrismaBillingStore(database.client); + + const notifications = await store.createUpcomingRenewalInvoices({ + paymentProvider: "nowpayments", + paymentProviderAdapter: { + createInvoice: async () => ({ + providerInvoiceId: "provider_invoice_renewal_1", + paymentAddress: "wallet_renewal_1", + amountCrypto: 29, + amountUsd: 29, + currency: "USDT", + expiresAt: new Date("2026-03-10T13:00:00.000Z"), + }), + getInvoiceStatus: async (providerInvoiceId) => ({ + providerInvoiceId, + status: "pending", + }), + }, + renewalLeadTimeHours: 72, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0]?.email, "user_subscription_1@example.com"); + assert.equal(notifications[0]?.subscriptionId, "subscription_1"); + assert.equal(database.calls.paymentInvoiceCreate.length, 1); +}); + +test("createUpcomingRenewalInvoices does not auto-create another invoice after one already exists in the current cycle", async () => { + const currentPeriodStart = new Date("2026-03-01T12:00:00.000Z"); + const database = createRenewalBillingDatabase({ + subscriptions: [ + createRenewalSubscriptionFixture({ + id: "subscription_1", + currentPeriodStart, + currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"), + }), + ], + existingInvoicesBySubscriptionId: { + subscription_1: [ + createInvoiceFixture({ + id: "invoice_existing", + status: "expired", + createdAt: new Date("2026-03-09T09:00:00.000Z"), + paidAt: null, + subscription: createSubscriptionFixture({ + id: "subscription_1", + currentPeriodStart, + currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"), + status: "active", + }), + }), + ], + }, + }); + const store = createPrismaBillingStore(database.client); + + const notifications = await store.createUpcomingRenewalInvoices({ + paymentProvider: "nowpayments", + paymentProviderAdapter: { + createInvoice: async () => { + throw new Error("createInvoice should not be called"); + }, + getInvoiceStatus: async (providerInvoiceId) => ({ + providerInvoiceId, + status: "pending", + }), + }, + renewalLeadTimeHours: 72, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + assert.equal(notifications.length, 0); + assert.equal(database.calls.paymentInvoiceCreate.length, 0); +}); + +test("createUpcomingRenewalInvoices acquires a subscription billing lock before creating an invoice", async () => { + const database = createRenewalBillingDatabase({ + subscriptions: [ + createRenewalSubscriptionFixture({ + id: "subscription_1", + currentPeriodStart: new Date("2026-03-01T12:00:00.000Z"), + currentPeriodEnd: new Date("2026-03-12T11:00:00.000Z"), + }), + ], + }); + const store = createPrismaBillingStore(database.client); + + const notifications = await store.createUpcomingRenewalInvoices({ + paymentProvider: "nowpayments", + paymentProviderAdapter: { + createInvoice: async () => ({ + providerInvoiceId: "provider_invoice_renewal_1", + paymentAddress: "wallet_renewal_1", + amountCrypto: 29, + amountUsd: 29, + currency: "USDT", + expiresAt: new Date("2026-03-10T13:00:00.000Z"), + }), + getInvoiceStatus: async (providerInvoiceId) => ({ + providerInvoiceId, + status: "pending", + }), + }, + renewalLeadTimeHours: 72, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + assert.equal(notifications.length, 1); + assert.equal(database.calls.paymentInvoiceCreate.length, 1); + assert.deepEqual(database.calls.subscriptionBillingLocks, ["subscription_1"]); +}); + +test("expireElapsedPendingInvoices marks pending invoices expired", async () => { + const database = createRenewalBillingDatabase({ + subscriptions: [], + expirePendingCount: 2, + }); + const store = createPrismaBillingStore(database.client); + + const result = await store.expireElapsedPendingInvoices(new Date("2026-03-10T12:00:00.000Z")); + + assert.equal(result.expiredCount, 2); + assert.equal(database.calls.paymentInvoiceExpireUpdateMany.length, 1); +}); + test("markInvoicePaid activates a pending invoice once and writes an admin audit log", async () => { const invoice = createInvoiceFixture({ status: "pending", @@ -149,6 +285,118 @@ test("markInvoicePaid treats a concurrent pending->paid race as a replay without assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true); }); +test("reconcilePendingInvoice marks a pending invoice paid using provider paidAt", async () => { + const providerPaidAt = new Date("2026-03-10T12:34:56.000Z"); + const database = createBillingDatabase({ + invoice: createInvoiceFixture({ + status: "pending", + paidAt: null, + subscription: createSubscriptionFixture(), + }), + }); + const store = createPrismaBillingStore(database.client); + + const result = await store.reconcilePendingInvoice({ + invoiceId: "invoice_1", + providerStatus: "paid", + paidAt: providerPaidAt, + actor: { + type: "system", + ref: "invoice_reconciliation", + }, + }); + + assert.equal(result.outcome, "marked_paid"); + assert.equal(result.invoice.paidAt?.toISOString(), providerPaidAt.toISOString()); + assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1); + assert.equal( + ( + database.calls.paymentInvoiceUpdateMany[0] as { + data: { paidAt: Date }; + } + ).data.paidAt.toISOString(), + providerPaidAt.toISOString(), + ); + assert.equal( + ( + database.calls.subscriptionUpdate[0] as { + data: { currentPeriodStart: Date }; + } + ).data.currentPeriodStart.toISOString(), + providerPaidAt.toISOString(), + ); +}); + +test("reconcilePendingInvoice marks a pending invoice expired", async () => { + const database = createBillingDatabase({ + invoice: createInvoiceFixture({ + status: "pending", + paidAt: null, + subscription: createSubscriptionFixture(), + }), + }); + const store = createPrismaBillingStore(database.client); + + const result = await store.reconcilePendingInvoice({ + invoiceId: "invoice_1", + providerStatus: "expired", + }); + + assert.equal(result.outcome, "marked_expired"); + assert.equal(result.invoice.status, "expired"); + assert.equal(database.calls.paymentInvoiceTerminalUpdateMany.length, 1); + assert.equal(database.calls.subscriptionUpdate.length, 0); + assert.equal(database.calls.usageLedgerCreate.length, 0); +}); + +test("reconcilePendingInvoice does not override an already paid invoice with expired status", async () => { + const paidAt = new Date("2026-03-10T12:00:00.000Z"); + const database = createBillingDatabase({ + invoice: createInvoiceFixture({ + status: "paid", + paidAt, + subscription: createSubscriptionFixture(), + }), + }); + const store = createPrismaBillingStore(database.client); + + const result = await store.reconcilePendingInvoice({ + invoiceId: "invoice_1", + providerStatus: "expired", + }); + + assert.equal(result.outcome, "ignored_terminal_state"); + assert.equal(result.invoice.status, "paid"); + assert.equal(database.calls.paymentInvoiceTerminalUpdateMany.length, 0); +}); + +test("reconcilePendingInvoice accepts provider paid for a locally expired invoice", async () => { + const providerPaidAt = new Date("2026-03-10T12:34:56.000Z"); + const database = createBillingDatabase({ + invoice: createInvoiceFixture({ + status: "expired", + paidAt: null, + subscription: createSubscriptionFixture(), + }), + }); + const store = createPrismaBillingStore(database.client); + + const result = await store.reconcilePendingInvoice({ + invoiceId: "invoice_1", + providerStatus: "paid", + paidAt: providerPaidAt, + actor: { + type: "system", + ref: "invoice_reconciliation", + }, + }); + + assert.equal(result.outcome, "marked_paid"); + assert.equal(result.invoice.status, "paid"); + assert.equal(result.invoice.paidAt?.toISOString(), providerPaidAt.toISOString()); + assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1); +}); + function createBillingDatabase(input: { invoice: ReturnType; updateManyCount?: number; @@ -156,6 +404,7 @@ function createBillingDatabase(input: { }) { const calls = { paymentInvoiceUpdateMany: [] as Array>, + paymentInvoiceTerminalUpdateMany: [] as Array>, subscriptionUpdate: [] as Array>, usageLedgerCreate: [] as Array>, adminAuditCreate: [] as Array>, @@ -183,14 +432,18 @@ function createBillingDatabase(input: { where, data, }: { - where: { id: string; status: "pending" }; + where: { id: string; status: "pending" | { in: Array<"pending" | "expired" | "canceled"> } }; data: { status: "paid"; paidAt: Date }; }) => { calls.paymentInvoiceUpdateMany.push({ where, data }); + const allowedStatuses = + (typeof where.status === "string" ? [where.status] : where.status.in) as Array< + "pending" | "expired" | "canceled" | "paid" + >; const count = input.updateManyCount ?? - (currentInvoice.id === where.id && currentInvoice.status === where.status ? 1 : 0); + (currentInvoice.id === where.id && allowedStatuses.includes(currentInvoice.status) ? 1 : 0); if (count > 0) { currentInvoice = { @@ -224,6 +477,29 @@ function createBillingDatabase(input: { }; const client = { + paymentInvoice: { + findUnique: async () => currentInvoice, + updateMany: async ({ + where, + data, + }: { + where: { id: string; status: "pending" }; + data: { status: "expired" | "canceled" }; + }) => { + calls.paymentInvoiceTerminalUpdateMany.push({ where, data }); + + const count = currentInvoice.id === where.id && currentInvoice.status === where.status ? 1 : 0; + + if (count > 0) { + currentInvoice = { + ...currentInvoice, + status: data.status, + }; + } + + return { count }; + }, + }, $transaction: async (callback: (tx: typeof transaction) => Promise) => callback(transaction), } as unknown as Parameters[0]; @@ -234,12 +510,14 @@ function createBillingDatabase(input: { } function createInvoiceFixture(input: { + id?: string; status: "pending" | "paid" | "expired" | "canceled"; paidAt: Date | null; + createdAt?: Date; subscription: ReturnType | null; }) { return { - id: "invoice_1", + id: input.id ?? "invoice_1", userId: "user_1", subscriptionId: input.subscription?.id ?? null, provider: "nowpayments", @@ -251,22 +529,30 @@ function createInvoiceFixture(input: { 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"), + createdAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"), + updatedAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"), subscription: input.subscription, }; } -function createSubscriptionFixture() { +function createSubscriptionFixture( + overrides?: Partial<{ + id: string; + status: "pending_activation" | "active" | "expired"; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + activatedAt: Date | null; + }>, +) { return { - id: "subscription_1", + id: overrides?.id ?? "subscription_1", userId: "user_1", planId: "plan_1", - status: "pending_activation" as const, + status: overrides?.status ?? ("pending_activation" as const), renewsManually: true, - activatedAt: null, - currentPeriodStart: null, - currentPeriodEnd: null, + activatedAt: overrides?.activatedAt ?? null, + currentPeriodStart: overrides?.currentPeriodStart ?? null, + currentPeriodEnd: overrides?.currentPeriodEnd ?? null, canceledAt: null, createdAt: new Date("2026-03-10T11:00:00.000Z"), updatedAt: new Date("2026-03-10T11:00:00.000Z"), @@ -283,3 +569,118 @@ function createSubscriptionFixture() { }, }; } + +function createRenewalBillingDatabase(input: { + subscriptions: Array>; + existingInvoicesBySubscriptionId?: Record[]>; + expirePendingCount?: number; +}) { + const calls = { + paymentInvoiceCreate: [] as Array>, + paymentInvoiceFindFirst: [] as Array>, + paymentInvoiceExpireUpdateMany: [] as Array>, + subscriptionBillingLocks: [] as string[], + }; + + const transaction = { + $queryRaw: async (strings: TemplateStringsArray, subscriptionId: string) => { + if (strings[0]?.includes('FROM "Subscription"')) { + calls.subscriptionBillingLocks.push(subscriptionId); + } + + return []; + }, + subscription: { + findMany: async () => input.subscriptions, + }, + paymentInvoice: { + findFirst: async ({ + where, + }: { + where: { + subscriptionId: string; + createdAt: { gte: Date }; + }; + }) => { + calls.paymentInvoiceFindFirst.push({ where }); + const invoices = input.existingInvoicesBySubscriptionId?.[where.subscriptionId] ?? []; + return ( + invoices + .filter((invoice) => invoice.createdAt >= where.createdAt.gte) + .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())[0] ?? null + ); + }, + create: async ({ data }: { data: Record }) => { + calls.paymentInvoiceCreate.push({ data }); + return { + id: "invoice_created_1", + subscriptionId: data.subscriptionId as string, + provider: data.provider as string, + providerInvoiceId: data.providerInvoiceId as string, + status: "pending" as const, + currency: data.currency as string, + amountCrypto: new Prisma.Decimal(String(data.amountCrypto)), + amountUsd: new Prisma.Decimal(String(data.amountUsd)), + paymentAddress: data.paymentAddress as string, + expiresAt: data.expiresAt as Date, + paidAt: null, + createdAt: new Date("2026-03-09T12:00:00.000Z"), + updatedAt: new Date("2026-03-09T12:00:00.000Z"), + }; + }, + updateMany: async ({ + where, + data, + }: { + where: { status: "pending"; expiresAt: { lte: Date } }; + data: { status: "expired" }; + }) => { + calls.paymentInvoiceExpireUpdateMany.push({ where, data }); + return { count: input.expirePendingCount ?? 0 }; + }, + }, + }; + + const client = { + subscription: transaction.subscription, + paymentInvoice: transaction.paymentInvoice, + $transaction: async (callback: (tx: typeof transaction) => Promise) => callback(transaction), + } as unknown as Parameters[0]; + + return { + client, + calls, + }; +} + +function createRenewalSubscriptionFixture(input: { + id: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; +}) { + return { + id: input.id, + userId: `user_${input.id}`, + planId: "plan_1", + status: "active" as const, + renewsManually: true, + activatedAt: input.currentPeriodStart, + currentPeriodStart: input.currentPeriodStart, + currentPeriodEnd: input.currentPeriodEnd, + canceledAt: null, + createdAt: input.currentPeriodStart, + updatedAt: input.currentPeriodStart, + user: { + id: `user_${input.id}`, + email: `user_${input.id}@example.com`, + }, + plan: { + id: "plan_1", + code: "monthly", + displayName: "Monthly", + monthlyRequestLimit: 100, + monthlyPriceUsd: new Prisma.Decimal("29"), + billingCurrency: "USDT", + }, + }; +} diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts index c8e552c..e52ca97 100644 --- a/packages/db/src/billing-store.ts +++ b/packages/db/src/billing-store.ts @@ -1,4 +1,4 @@ -import type { PaymentProviderAdapter } from "@nproxy/providers"; +import type { PaymentProviderAdapter, ProviderInvoiceStatus } from "@nproxy/providers"; import { Prisma, type AdminActorType, @@ -48,6 +48,38 @@ export interface BillingActorMetadata { ref?: string; } +export interface ExpirePendingInvoicesResult { + expiredCount: number; +} + +export interface RenewalInvoiceNotification { + userId: string; + email: string; + subscriptionId: string; + subscriptionCurrentPeriodEnd: Date; + invoice: BillingInvoiceRecord; +} + +export interface PendingInvoiceReconciliationRecord extends BillingInvoiceRecord { + userId: string; + providerInvoiceId: string; +} + +export type InvoiceReconciliationOutcome = + | "noop_pending" + | "marked_paid" + | "marked_expired" + | "marked_canceled" + | "already_paid" + | "already_expired" + | "already_canceled" + | "ignored_terminal_state"; + +export interface InvoiceReconciliationResult { + outcome: InvoiceReconciliationOutcome; + invoice: BillingInvoiceRecord; +} + export class BillingError extends Error { constructor( readonly code: "invoice_not_found" | "invoice_transition_not_allowed", @@ -58,7 +90,7 @@ export class BillingError extends Error { } export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) { - return { + const store = { async listUserInvoices(userId: string): Promise { const invoices = await database.paymentInvoice.findMany({ where: { userId }, @@ -92,180 +124,475 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) paymentProvider: string; paymentProviderAdapter: PaymentProviderAdapter; }): Promise { - const subscription = await database.subscription.findFirst({ - where: { userId: input.userId }, - include: { plan: true }, - orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], - }); + return database.$transaction(async (transaction) => { + const subscription = await transaction.subscription.findFirst({ + where: { userId: input.userId }, + include: { plan: true }, + orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }], + }); - if (!subscription) { - throw new Error("Subscription not found."); - } + if (!subscription) { + throw new Error("Subscription not found."); + } - const existingPending = await database.paymentInvoice.findFirst({ - where: { + await lockSubscriptionForBilling(transaction, subscription.id); + + const existingPending = await transaction.paymentInvoice.findFirst({ + where: { + userId: input.userId, + subscriptionId: subscription.id, + status: "pending", + expiresAt: { + gt: new Date(), + }, + }, + orderBy: { createdAt: "desc" }, + }); + + if (existingPending) { + return mapInvoice(existingPending); + } + + const amountUsd = subscription.plan.monthlyPriceUsd.toNumber(); + const currency = subscription.plan.billingCurrency; + const amountCrypto = amountUsd; + const providerInvoice = await input.paymentProviderAdapter.createInvoice({ userId: input.userId, - subscriptionId: subscription.id, + planCode: subscription.plan.code, + amountUsd, + amountCrypto, + currency, + }); + + const invoice = await transaction.paymentInvoice.create({ + data: { + userId: input.userId, + subscriptionId: subscription.id, + provider: input.paymentProvider, + providerInvoiceId: providerInvoice.providerInvoiceId, + status: "pending", + currency: providerInvoice.currency, + amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto), + amountUsd: new Prisma.Decimal(providerInvoice.amountUsd), + paymentAddress: providerInvoice.paymentAddress, + expiresAt: providerInvoice.expiresAt, + }, + }); + + return mapInvoice(invoice); + }); + }, + + async expireElapsedPendingInvoices( + now: Date = new Date(), + ): Promise { + const result = await database.paymentInvoice.updateMany({ + where: { status: "pending", expiresAt: { - gt: new Date(), + lte: now, }, }, - orderBy: { createdAt: "desc" }, - }); - - if (existingPending) { - return mapInvoice(existingPending); - } - - const amountUsd = subscription.plan.monthlyPriceUsd.toNumber(); - const currency = subscription.plan.billingCurrency; - const amountCrypto = amountUsd; - const providerInvoice = await input.paymentProviderAdapter.createInvoice({ - userId: input.userId, - planCode: subscription.plan.code, - amountUsd, - amountCrypto, - currency, - }); - - const invoice = await database.paymentInvoice.create({ data: { - userId: input.userId, - subscriptionId: subscription.id, - provider: input.paymentProvider, - providerInvoiceId: providerInvoice.providerInvoiceId, - status: "pending", - currency: providerInvoice.currency, - amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto), - amountUsd: new Prisma.Decimal(providerInvoice.amountUsd), - paymentAddress: providerInvoice.paymentAddress, - expiresAt: providerInvoice.expiresAt, + status: "expired", }, }); - return mapInvoice(invoice); + return { + expiredCount: result.count, + }; + }, + + async createUpcomingRenewalInvoices(input: { + paymentProvider: string; + paymentProviderAdapter: PaymentProviderAdapter; + renewalLeadTimeHours: number; + now?: Date; + }): Promise { + const now = input.now ?? new Date(); + const renewalWindowEnd = addHours(now, input.renewalLeadTimeHours); + const subscriptions = await database.subscription.findMany({ + where: { + status: "active", + renewsManually: true, + currentPeriodEnd: { + gt: now, + lte: renewalWindowEnd, + }, + }, + include: { + user: true, + plan: true, + }, + orderBy: { + currentPeriodEnd: "asc", + }, + }); + + const notifications: RenewalInvoiceNotification[] = []; + + for (const subscription of subscriptions) { + if (!subscription.currentPeriodEnd) { + continue; + } + + const cycleStart = + subscription.currentPeriodStart ?? subscription.activatedAt ?? subscription.createdAt; + const subscriptionCurrentPeriodEnd = subscription.currentPeriodEnd; + const notification = await database.$transaction(async (transaction) => { + await lockSubscriptionForBilling(transaction, subscription.id); + + const existingCycleInvoice = await transaction.paymentInvoice.findFirst({ + where: { + subscriptionId: subscription.id, + createdAt: { + gte: cycleStart, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (existingCycleInvoice) { + return null; + } + + const amountUsd = subscription.plan.monthlyPriceUsd.toNumber(); + const currency = subscription.plan.billingCurrency; + const amountCrypto = amountUsd; + const providerInvoice = await input.paymentProviderAdapter.createInvoice({ + userId: subscription.userId, + planCode: subscription.plan.code, + amountUsd, + amountCrypto, + currency, + }); + + const invoice = await transaction.paymentInvoice.create({ + data: { + userId: subscription.userId, + subscriptionId: subscription.id, + provider: input.paymentProvider, + providerInvoiceId: providerInvoice.providerInvoiceId, + status: "pending", + currency: providerInvoice.currency, + amountCrypto: new Prisma.Decimal(providerInvoice.amountCrypto), + amountUsd: new Prisma.Decimal(providerInvoice.amountUsd), + paymentAddress: providerInvoice.paymentAddress, + expiresAt: providerInvoice.expiresAt, + }, + }); + + return { + userId: subscription.userId, + email: subscription.user.email, + subscriptionId: subscription.id, + subscriptionCurrentPeriodEnd, + invoice: mapInvoice(invoice), + } satisfies RenewalInvoiceNotification; + }); + + if (notification) { + notifications.push(notification); + } + } + + return notifications; }, async markInvoicePaid(input: { invoiceId: string; actor?: BillingActorMetadata; + paidAt?: Date; }): Promise { - return database.$transaction(async (transaction) => { - const invoice = await transaction.paymentInvoice.findUnique({ - where: { id: input.invoiceId }, - include: { - subscription: { - include: { - plan: true, - }, - }, + const result = await markInvoicePaidInternal(database, input); + return result.invoice; + }, + + async listPendingInvoicesForReconciliation( + limit: number = 100, + ): Promise { + const invoices = await database.paymentInvoice.findMany({ + where: { + status: "pending", + providerInvoiceId: { + not: null, }, - }); - - if (!invoice) { - 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 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; - const periodEnd = addDays(periodStart, 30); - - await transaction.subscription.update({ - where: { id: invoice.subscription.id }, - data: { - status: "active", - activatedAt: invoice.subscription.activatedAt ?? paidAt, - currentPeriodStart: periodStart, - currentPeriodEnd: periodEnd, - canceledAt: null, - }, - }); - - await transaction.usageLedgerEntry.create({ - data: { - userId: invoice.userId, - entryType: "cycle_reset", - deltaRequests: 0, - cycleStartedAt: periodStart, - cycleEndsAt: periodEnd, - note: `Cycle activated from invoice ${invoice.id}.`, - }, - }); - } - - await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false); - - return mapInvoice(updatedInvoice); + }, + orderBy: { + createdAt: "asc", + }, + take: limit, }); + + return invoices + .filter( + (invoice): invoice is typeof invoice & { providerInvoiceId: string } => + invoice.providerInvoiceId !== null, + ) + .map((invoice) => ({ + userId: invoice.userId, + providerInvoiceId: invoice.providerInvoiceId, + ...mapInvoice(invoice), + })); + }, + + async reconcilePendingInvoice(input: { + invoiceId: string; + providerStatus: ProviderInvoiceStatus; + paidAt?: Date; + actor?: BillingActorMetadata; + }): Promise { + const currentInvoice = await database.paymentInvoice.findUnique({ + where: { id: input.invoiceId }, + }); + + if (!currentInvoice) { + throw new BillingError("invoice_not_found", "Invoice not found."); + } + + if (input.providerStatus === "pending") { + return { + outcome: "noop_pending", + invoice: mapInvoice(currentInvoice), + }; + } + + if (input.providerStatus === "paid") { + try { + const result = await markInvoicePaidInternal(database, { + invoiceId: input.invoiceId, + ...(input.actor ? { actor: input.actor } : {}), + ...(input.paidAt ? { paidAt: input.paidAt } : {}), + allowedSourceStatuses: ["pending", "expired", "canceled"], + }); + + return { + outcome: result.replayed ? "already_paid" : "marked_paid", + invoice: result.invoice, + }; + } catch (error) { + if ( + error instanceof BillingError && + error.code === "invoice_transition_not_allowed" + ) { + const terminalInvoice = await database.paymentInvoice.findUnique({ + where: { id: input.invoiceId }, + }); + + if (terminalInvoice) { + return { + outcome: "ignored_terminal_state", + invoice: mapInvoice(terminalInvoice), + }; + } + } + + throw error; + } + } + + const targetStatus = input.providerStatus; + + if (currentInvoice.status === "paid") { + return { + outcome: "ignored_terminal_state", + invoice: mapInvoice(currentInvoice), + }; + } + + if (currentInvoice.status === targetStatus) { + return { + outcome: targetStatus === "expired" ? "already_expired" : "already_canceled", + invoice: mapInvoice(currentInvoice), + }; + } + + if (currentInvoice.status !== "pending") { + return { + outcome: "ignored_terminal_state", + invoice: mapInvoice(currentInvoice), + }; + } + + const transitionResult = await database.paymentInvoice.updateMany({ + where: { + id: input.invoiceId, + status: "pending", + }, + data: { + status: targetStatus, + }, + }); + + const updatedInvoice = await database.paymentInvoice.findUnique({ + where: { id: input.invoiceId }, + }); + + if (!updatedInvoice) { + throw new BillingError("invoice_not_found", "Invoice not found."); + } + + if (transitionResult.count > 0) { + return { + outcome: targetStatus === "expired" ? "marked_expired" : "marked_canceled", + invoice: mapInvoice(updatedInvoice), + }; + } + + if (updatedInvoice.status === targetStatus) { + return { + outcome: targetStatus === "expired" ? "already_expired" : "already_canceled", + invoice: mapInvoice(updatedInvoice), + }; + } + + return { + outcome: "ignored_terminal_state", + invoice: mapInvoice(updatedInvoice), + }; }, }; + + return store; +} + +async function markInvoicePaidInternal( + database: PrismaClient, + input: { + invoiceId: string; + actor?: BillingActorMetadata; + paidAt?: Date; + allowedSourceStatuses?: PaymentInvoiceStatus[]; + }, +): Promise<{ invoice: BillingInvoiceRecord; replayed: boolean }> { + return database.$transaction(async (transaction) => { + const invoice = await transaction.paymentInvoice.findUnique({ + where: { id: input.invoiceId }, + include: { + subscription: { + include: { + plan: true, + }, + }, + }, + }); + + if (!invoice) { + throw new BillingError("invoice_not_found", "Invoice not found."); + } + + if (invoice.status === "paid") { + await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true); + return { + invoice: mapInvoice(invoice), + replayed: true, + }; + } + + const allowedSourceStatuses = input.allowedSourceStatuses ?? ["pending"]; + + if (!allowedSourceStatuses.includes(invoice.status)) { + throw new BillingError( + "invoice_transition_not_allowed", + `Invoice in status "${invoice.status}" cannot be marked paid.`, + ); + } + + const paidAt = invoice.paidAt ?? input.paidAt ?? new Date(); + const transitionResult = await transaction.paymentInvoice.updateMany({ + where: { + id: invoice.id, + status: { + in: allowedSourceStatuses, + }, + }, + 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 { + invoice: mapInvoice(currentInvoice), + replayed: true, + }; + } + + 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; + const periodEnd = addDays(periodStart, 30); + + await transaction.subscription.update({ + where: { id: invoice.subscription.id }, + data: { + status: "active", + activatedAt: invoice.subscription.activatedAt ?? paidAt, + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + canceledAt: null, + }, + }); + + await transaction.usageLedgerEntry.create({ + data: { + userId: invoice.userId, + entryType: "cycle_reset", + deltaRequests: 0, + cycleStartedAt: periodStart, + cycleEndsAt: periodEnd, + note: `Cycle activated from invoice ${invoice.id}.`, + }, + }); + } + + await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false); + + return { + invoice: mapInvoice(updatedInvoice), + replayed: false, + }; + }); } async function writeInvoicePaidAuditLog( @@ -376,3 +703,14 @@ function mapSubscription(subscription: { function addDays(value: Date, days: number): Date { return new Date(value.getTime() + days * 24 * 60 * 60 * 1000); } + +function addHours(value: Date, hours: number): Date { + return new Date(value.getTime() + hours * 60 * 60 * 1000); +} + +async function lockSubscriptionForBilling( + database: Pick, + subscriptionId: string, +): Promise { + await database.$queryRaw`SELECT 1 FROM "Subscription" WHERE id = ${subscriptionId} FOR UPDATE`; +} diff --git a/packages/providers/src/payments.ts b/packages/providers/src/payments.ts index 993395c..11b8016 100644 --- a/packages/providers/src/payments.ts +++ b/packages/providers/src/payments.ts @@ -17,8 +17,18 @@ export interface CreatedProviderInvoice { expiresAt: Date; } +export type ProviderInvoiceStatus = "pending" | "paid" | "expired" | "canceled"; + +export interface ProviderInvoiceStatusRecord { + providerInvoiceId: string; + status: ProviderInvoiceStatus; + paidAt?: Date; + expiresAt?: Date; +} + export interface PaymentProviderAdapter { createInvoice(input: PaymentInvoiceDraft): Promise; + getInvoiceStatus(providerInvoiceId: string): Promise; } export function createPaymentProviderAdapter(config: { @@ -37,6 +47,13 @@ export function createPaymentProviderAdapter(config: { expiresAt: new Date(Date.now() + 30 * 60 * 1000), }; }, + + async getInvoiceStatus(providerInvoiceId) { + return { + providerInvoiceId, + status: "pending", + }; + }, }; } @@ -51,5 +68,12 @@ export function createPaymentProviderAdapter(config: { expiresAt: new Date(Date.now() + 30 * 60 * 1000), }; }, + + async getInvoiceStatus(providerInvoiceId) { + return { + providerInvoiceId, + status: "pending", + }; + }, }; }