From 55383deaf4f7e5f8a0791fe323966a74a20ddd0a Mon Sep 17 00:00:00 2001 From: sirily Date: Wed, 11 Mar 2026 12:09:30 +0300 Subject: [PATCH] feat: add invoice polling reconciliation --- apps/worker/src/main.ts | 103 ++++++- docs/ops/payment-system.md | 56 ++-- packages/db/src/billing-store.test.ts | 117 +++++++ packages/db/src/billing-store.ts | 420 +++++++++++++++++++------- packages/providers/src/payments.ts | 24 ++ 5 files changed, 575 insertions(+), 145 deletions(-) diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 3410857..59c293c 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -9,6 +9,7 @@ import { 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, @@ -53,18 +54,6 @@ async function runTick(): Promise { isTickRunning = true; try { - const expiredInvoices = await billingStore.expireElapsedPendingInvoices(); - - if (expiredInvoices.expiredCount > 0) { - console.log( - JSON.stringify({ - service: "worker", - event: "pending_invoices_expired", - expiredCount: expiredInvoices.expiredCount, - }), - ); - } - const renewalNotifications = await billingStore.createUpcomingRenewalInvoices({ paymentProvider: config.payment.provider, paymentProviderAdapter, @@ -101,6 +90,96 @@ async function runTick(): Promise { ); } + 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); + 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 b938ce9..588a55a 100644 --- a/docs/ops/payment-system.md +++ b/docs/ops/payment-system.md @@ -12,15 +12,15 @@ The current payment system covers: - 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 @@ -77,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 @@ -113,7 +115,18 @@ Current rule: - `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 `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. ## Invoice listing flow - `GET /api/billing/invoices` returns the user's invoices ordered by newest first. @@ -121,13 +134,13 @@ Current rule: - 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` @@ -136,8 +149,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. @@ -193,16 +211,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 7adcdd5..3d4e8e8 100644 --- a/packages/db/src/billing-store.test.ts +++ b/packages/db/src/billing-store.test.ts @@ -26,6 +26,10 @@ test("createUpcomingRenewalInvoices creates one invoice for subscriptions enteri 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"), @@ -72,6 +76,10 @@ test("createUpcomingRenewalInvoices does not auto-create another invoice after o 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"), @@ -240,6 +248,91 @@ 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); +}); + function createBillingDatabase(input: { invoice: ReturnType; updateManyCount?: number; @@ -247,6 +340,7 @@ function createBillingDatabase(input: { }) { const calls = { paymentInvoiceUpdateMany: [] as Array>, + paymentInvoiceTerminalUpdateMany: [] as Array>, subscriptionUpdate: [] as Array>, usageLedgerCreate: [] as Array>, adminAuditCreate: [] as Array>, @@ -315,6 +409,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]; diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts index b1226bc..6711171 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, @@ -60,6 +60,26 @@ export interface RenewalInvoiceNotification { 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", @@ -70,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 }, @@ -271,122 +291,296 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) 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") { + if (currentInvoice.status === "expired" || currentInvoice.status === "canceled") { + return { + outcome: "ignored_terminal_state", + invoice: mapInvoice(currentInvoice), + }; + } + + try { + const result = await markInvoicePaidInternal(database, { + invoiceId: input.invoiceId, + ...(input.actor ? { actor: input.actor } : {}), + ...(input.paidAt ? { paidAt: input.paidAt } : {}), + }); + + 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; + }, +): 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 === "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 { + invoice: mapInvoice(invoice), + replayed: true, + }; + } + + const paidAt = invoice.paidAt ?? input.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 { + 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( 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", + }; + }, }; }