feat: add invoice polling reconciliation

This commit is contained in:
sirily
2026-03-11 12:09:30 +03:00
parent eb5272d2cb
commit 55383deaf4
5 changed files with 575 additions and 145 deletions

View File

@@ -9,6 +9,7 @@ import {
const config = loadConfig(); const config = loadConfig();
const intervalMs = config.keyPool.balancePollSeconds * 1000; const intervalMs = config.keyPool.balancePollSeconds * 1000;
const renewalLeadTimeHours = 72; const renewalLeadTimeHours = 72;
const invoiceReconciliationBatchSize = 100;
const workerStore = createPrismaWorkerStore(prisma, { const workerStore = createPrismaWorkerStore(prisma, {
cooldownMinutes: config.keyPool.cooldownMinutes, cooldownMinutes: config.keyPool.cooldownMinutes,
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview, failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
@@ -53,18 +54,6 @@ async function runTick(): Promise<void> {
isTickRunning = true; isTickRunning = true;
try { 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({ const renewalNotifications = await billingStore.createUpcomingRenewalInvoices({
paymentProvider: config.payment.provider, paymentProvider: config.payment.provider,
paymentProviderAdapter, paymentProviderAdapter,
@@ -101,6 +90,96 @@ async function runTick(): Promise<void> {
); );
} }
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(); const recovery = await workerStore.recoverCooldownProviderKeys();
if (recovery.recoveredCount > 0) { if (recovery.recoveredCount > 0) {

View File

@@ -12,15 +12,15 @@ The current payment system covers:
- invoice creation through a provider adapter - invoice creation through a provider adapter
- automatic renewal-invoice creation `72 hours` before `currentPeriodEnd` - 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 - 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 - 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 subscription periods during account and generation access checks
- automatic expiry of elapsed pending invoices - automatic expiry of elapsed pending invoices
- automatic provider-driven `expired` and `canceled` transitions for pending invoices
- quota-cycle reset on successful activation - quota-cycle reset on successful activation
The current payment system does not yet cover: The current payment system does not yet cover:
- provider webhooks - provider webhooks
- polling-based reconciliation
- automatic `expired` or `canceled` transitions
- recurring billing - recurring billing
## Main records ## Main records
@@ -77,10 +77,12 @@ Payment-provider code stays behind `packages/providers/src/payments.ts`.
Current provider contract: Current provider contract:
- `createInvoice(input) -> providerInvoiceId, paymentAddress, amountCrypto, amountUsd, currency, expiresAt` - `createInvoice(input) -> providerInvoiceId, paymentAddress, amountCrypto, amountUsd, currency, expiresAt`
- `getInvoiceStatus(providerInvoiceId) -> pending|paid|expired|canceled + paidAt? + expiresAt?`
Current runtime note: Current runtime note:
- the provider adapter is still a placeholder adapter - 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 - the rest of the payment flow is intentionally provider-agnostic
## Registration flow ## Registration flow
@@ -113,7 +115,18 @@ Current rule:
- `pending` does not activate the subscription. - `pending` does not activate the subscription.
- `pending` does not reset quota. - `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 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 ## Invoice listing flow
- `GET /api/billing/invoices` returns the user's invoices ordered by newest first. - `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. - The worker also marks `pending` invoices `expired` when `expiresAt` has passed.
## Current activation flow ## 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`. Shared activation behavior:
2. The web app resolves the admin session and passes actor metadata into the billing store. - `markInvoicePaid` runs inside one database transaction.
3. This endpoint is intended to be used only after the operator has already verified that the provider reached a final successful payment state. - If the invoice is `pending`, the store:
4. `markInvoicePaid` runs inside one database transaction.
5. If the invoice is `pending`, the store:
- updates the invoice to `paid` - updates the invoice to `paid`
- sets `paidAt` - sets `paidAt`
- updates the related subscription to `active` - updates the related subscription to `active`
@@ -136,8 +149,13 @@ The implemented activation path is manual and admin-driven.
- sets `currentPeriodEnd = paidAt + 30 days` - sets `currentPeriodEnd = paidAt + 30 days`
- clears `canceledAt` - clears `canceledAt`
- writes a `UsageLedgerEntry` with `entryType = cycle_reset` - writes a `UsageLedgerEntry` with `entryType = cycle_reset`
- writes an `AdminAuditLog` entry `invoice_mark_paid` - writes an `AdminAuditLog` entry `invoice_mark_paid` when actor metadata is present
6. The API returns the updated invoice.
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: Important constraint:
- `mark-paid` is not evidence by itself that a `pending` invoice became payable. - `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` - `invoice_transition_not_allowed` -> `409`
## Current limitations ## Current limitations
- The system still depends on manual admin confirmation to activate access. - The provider adapter still uses placeholder status lookups; real provider HTTP integration is not implemented yet.
- Because provider-final status is not ingested automatically yet, the app currently relies on operator judgment when calling `mark-paid`. - No provider callback or webhook signature verification path exists yet.
- No provider callback or reconciliation job updates invoice state automatically. - Manual admin `mark-paid` still exists as an override, so operator judgment is still part of the system for exceptional cases.
- No runtime path currently moves invoices to `expired` or `canceled`. - The worker polls invoice status in batches; there is no provider push path yet.
- 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.
## Required future direction ## Required future direction
- Add provider callbacks or polling-based reconciliation. - Replace placeholder provider status lookups with real provider integration.
- Persist provider-final status before activating access automatically. - 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. - Reduce or remove the need for operator judgment in the normal payment-success path.
## Code references ## Code references

View File

@@ -26,6 +26,10 @@ test("createUpcomingRenewalInvoices creates one invoice for subscriptions enteri
currency: "USDT", currency: "USDT",
expiresAt: new Date("2026-03-10T13:00:00.000Z"), expiresAt: new Date("2026-03-10T13:00:00.000Z"),
}), }),
getInvoiceStatus: async (providerInvoiceId) => ({
providerInvoiceId,
status: "pending",
}),
}, },
renewalLeadTimeHours: 72, renewalLeadTimeHours: 72,
now: new Date("2026-03-09T12:00:00.000Z"), 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 () => { createInvoice: async () => {
throw new Error("createInvoice should not be called"); throw new Error("createInvoice should not be called");
}, },
getInvoiceStatus: async (providerInvoiceId) => ({
providerInvoiceId,
status: "pending",
}),
}, },
renewalLeadTimeHours: 72, renewalLeadTimeHours: 72,
now: new Date("2026-03-09T12:00:00.000Z"), 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); 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: { function createBillingDatabase(input: {
invoice: ReturnType<typeof createInvoiceFixture>; invoice: ReturnType<typeof createInvoiceFixture>;
updateManyCount?: number; updateManyCount?: number;
@@ -247,6 +340,7 @@ function createBillingDatabase(input: {
}) { }) {
const calls = { const calls = {
paymentInvoiceUpdateMany: [] as Array<Record<string, unknown>>, paymentInvoiceUpdateMany: [] as Array<Record<string, unknown>>,
paymentInvoiceTerminalUpdateMany: [] as Array<Record<string, unknown>>,
subscriptionUpdate: [] as Array<Record<string, unknown>>, subscriptionUpdate: [] as Array<Record<string, unknown>>,
usageLedgerCreate: [] as Array<Record<string, unknown>>, usageLedgerCreate: [] as Array<Record<string, unknown>>,
adminAuditCreate: [] as Array<Record<string, any>>, adminAuditCreate: [] as Array<Record<string, any>>,
@@ -315,6 +409,29 @@ function createBillingDatabase(input: {
}; };
const client = { 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 <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction), $transaction: async <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction),
} as unknown as Parameters<typeof createPrismaBillingStore>[0]; } as unknown as Parameters<typeof createPrismaBillingStore>[0];

View File

@@ -1,4 +1,4 @@
import type { PaymentProviderAdapter } from "@nproxy/providers"; import type { PaymentProviderAdapter, ProviderInvoiceStatus } from "@nproxy/providers";
import { import {
Prisma, Prisma,
type AdminActorType, type AdminActorType,
@@ -60,6 +60,26 @@ export interface RenewalInvoiceNotification {
invoice: BillingInvoiceRecord; 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 { export class BillingError extends Error {
constructor( constructor(
readonly code: "invoice_not_found" | "invoice_transition_not_allowed", readonly code: "invoice_not_found" | "invoice_transition_not_allowed",
@@ -70,7 +90,7 @@ export class BillingError extends Error {
} }
export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) { export function createPrismaBillingStore(database: PrismaClient = defaultPrisma) {
return { const store = {
async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> { async listUserInvoices(userId: string): Promise<BillingInvoiceRecord[]> {
const invoices = await database.paymentInvoice.findMany({ const invoices = await database.paymentInvoice.findMany({
where: { userId }, where: { userId },
@@ -271,122 +291,296 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
async markInvoicePaid(input: { async markInvoicePaid(input: {
invoiceId: string; invoiceId: string;
actor?: BillingActorMetadata; actor?: BillingActorMetadata;
paidAt?: Date;
}): Promise<BillingInvoiceRecord> { }): Promise<BillingInvoiceRecord> {
return database.$transaction(async (transaction) => { const result = await markInvoicePaidInternal(database, input);
const invoice = await transaction.paymentInvoice.findUnique({ return result.invoice;
where: { id: input.invoiceId }, },
include: {
subscription: { async listPendingInvoicesForReconciliation(
include: { limit: number = 100,
plan: true, ): Promise<PendingInvoiceReconciliationRecord[]> {
}, const invoices = await database.paymentInvoice.findMany({
}, where: {
status: "pending",
providerInvoiceId: {
not: null,
}, },
}); },
orderBy: {
if (!invoice) { createdAt: "asc",
throw new BillingError("invoice_not_found", "Invoice not found."); },
} take: limit,
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);
}); });
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<InvoiceReconciliationResult> {
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( async function writeInvoicePaidAuditLog(

View File

@@ -17,8 +17,18 @@ export interface CreatedProviderInvoice {
expiresAt: Date; expiresAt: Date;
} }
export type ProviderInvoiceStatus = "pending" | "paid" | "expired" | "canceled";
export interface ProviderInvoiceStatusRecord {
providerInvoiceId: string;
status: ProviderInvoiceStatus;
paidAt?: Date;
expiresAt?: Date;
}
export interface PaymentProviderAdapter { export interface PaymentProviderAdapter {
createInvoice(input: PaymentInvoiceDraft): Promise<CreatedProviderInvoice>; createInvoice(input: PaymentInvoiceDraft): Promise<CreatedProviderInvoice>;
getInvoiceStatus(providerInvoiceId: string): Promise<ProviderInvoiceStatusRecord>;
} }
export function createPaymentProviderAdapter(config: { export function createPaymentProviderAdapter(config: {
@@ -37,6 +47,13 @@ export function createPaymentProviderAdapter(config: {
expiresAt: new Date(Date.now() + 30 * 60 * 1000), 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), expiresAt: new Date(Date.now() + 30 * 60 * 1000),
}; };
}, },
async getInvoiceStatus(providerInvoiceId) {
return {
providerInvoiceId,
status: "pending",
};
},
}; };
} }