feat: add invoice polling reconciliation
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +291,174 @@ 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> {
|
||||||
|
const result = await markInvoicePaidInternal(database, input);
|
||||||
|
return result.invoice;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPendingInvoicesForReconciliation(
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<PendingInvoiceReconciliationRecord[]> {
|
||||||
|
const invoices = await database.paymentInvoice.findMany({
|
||||||
|
where: {
|
||||||
|
status: "pending",
|
||||||
|
providerInvoiceId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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<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) => {
|
return database.$transaction(async (transaction) => {
|
||||||
const invoice = await transaction.paymentInvoice.findUnique({
|
const invoice = await transaction.paymentInvoice.findUnique({
|
||||||
where: { id: input.invoiceId },
|
where: { id: input.invoiceId },
|
||||||
@@ -297,10 +484,13 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
|
|
||||||
if (invoice.status === "paid") {
|
if (invoice.status === "paid") {
|
||||||
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
|
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
|
||||||
return mapInvoice(invoice);
|
return {
|
||||||
|
invoice: mapInvoice(invoice),
|
||||||
|
replayed: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const paidAt = invoice.paidAt ?? new Date();
|
const paidAt = invoice.paidAt ?? input.paidAt ?? new Date();
|
||||||
const transitionResult = await transaction.paymentInvoice.updateMany({
|
const transitionResult = await transaction.paymentInvoice.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: invoice.id,
|
id: invoice.id,
|
||||||
@@ -330,7 +520,10 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
|
|
||||||
if (currentInvoice.status === "paid") {
|
if (currentInvoice.status === "paid") {
|
||||||
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
|
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
|
||||||
return mapInvoice(currentInvoice);
|
return {
|
||||||
|
invoice: mapInvoice(currentInvoice),
|
||||||
|
replayed: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BillingError(
|
throw new BillingError(
|
||||||
@@ -383,10 +576,11 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
|
|
||||||
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
|
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
|
||||||
|
|
||||||
return mapInvoice(updatedInvoice);
|
return {
|
||||||
});
|
invoice: mapInvoice(updatedInvoice),
|
||||||
},
|
replayed: false,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeInvoicePaidAuditLog(
|
async function writeInvoicePaidAuditLog(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user