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 <sirily@git.shararam.party> Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
@@ -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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof createInvoiceFixture>;
|
||||
updateManyCount?: number;
|
||||
@@ -156,6 +404,7 @@ function createBillingDatabase(input: {
|
||||
}) {
|
||||
const calls = {
|
||||
paymentInvoiceUpdateMany: [] as Array<Record<string, unknown>>,
|
||||
paymentInvoiceTerminalUpdateMany: [] as Array<Record<string, unknown>>,
|
||||
subscriptionUpdate: [] as Array<Record<string, unknown>>,
|
||||
usageLedgerCreate: [] as Array<Record<string, unknown>>,
|
||||
adminAuditCreate: [] as Array<Record<string, any>>,
|
||||
@@ -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 <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction),
|
||||
} as unknown as Parameters<typeof createPrismaBillingStore>[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<typeof createSubscriptionFixture> | 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<ReturnType<typeof createRenewalSubscriptionFixture>>;
|
||||
existingInvoicesBySubscriptionId?: Record<string, ReturnType<typeof createInvoiceFixture>[]>;
|
||||
expirePendingCount?: number;
|
||||
}) {
|
||||
const calls = {
|
||||
paymentInvoiceCreate: [] as Array<Record<string, unknown>>,
|
||||
paymentInvoiceFindFirst: [] as Array<Record<string, unknown>>,
|
||||
paymentInvoiceExpireUpdateMany: [] as Array<Record<string, unknown>>,
|
||||
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<string, unknown> }) => {
|
||||
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 <T>(callback: (tx: typeof transaction) => Promise<T>) => callback(transaction),
|
||||
} as unknown as Parameters<typeof createPrismaBillingStore>[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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<BillingInvoiceRecord[]> {
|
||||
const invoices = await database.paymentInvoice.findMany({
|
||||
where: { userId },
|
||||
@@ -92,7 +124,8 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
paymentProvider: string;
|
||||
paymentProviderAdapter: PaymentProviderAdapter;
|
||||
}): Promise<BillingInvoiceRecord> {
|
||||
const subscription = await database.subscription.findFirst({
|
||||
return database.$transaction(async (transaction) => {
|
||||
const subscription = await transaction.subscription.findFirst({
|
||||
where: { userId: input.userId },
|
||||
include: { plan: true },
|
||||
orderBy: [{ currentPeriodEnd: "desc" }, { createdAt: "desc" }],
|
||||
@@ -102,7 +135,9 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
throw new Error("Subscription not found.");
|
||||
}
|
||||
|
||||
const existingPending = await database.paymentInvoice.findFirst({
|
||||
await lockSubscriptionForBilling(transaction, subscription.id);
|
||||
|
||||
const existingPending = await transaction.paymentInvoice.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
subscriptionId: subscription.id,
|
||||
@@ -129,7 +164,7 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
currency,
|
||||
});
|
||||
|
||||
const invoice = await database.paymentInvoice.create({
|
||||
const invoice = await transaction.paymentInvoice.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
subscriptionId: subscription.id,
|
||||
@@ -145,12 +180,293 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
});
|
||||
|
||||
return mapInvoice(invoice);
|
||||
});
|
||||
},
|
||||
|
||||
async expireElapsedPendingInvoices(
|
||||
now: Date = new Date(),
|
||||
): Promise<ExpirePendingInvoicesResult> {
|
||||
const result = await database.paymentInvoice.updateMany({
|
||||
where: {
|
||||
status: "pending",
|
||||
expiresAt: {
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: "expired",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
expiredCount: result.count,
|
||||
};
|
||||
},
|
||||
|
||||
async createUpcomingRenewalInvoices(input: {
|
||||
paymentProvider: string;
|
||||
paymentProviderAdapter: PaymentProviderAdapter;
|
||||
renewalLeadTimeHours: number;
|
||||
now?: Date;
|
||||
}): Promise<RenewalInvoiceNotification[]> {
|
||||
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<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") {
|
||||
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 },
|
||||
@@ -167,23 +483,30 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
throw new BillingError("invoice_not_found", "Invoice not found.");
|
||||
}
|
||||
|
||||
if (invoice.status === "canceled" || invoice.status === "expired") {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (invoice.status === "paid") {
|
||||
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
|
||||
return mapInvoice(invoice);
|
||||
}
|
||||
|
||||
const paidAt = invoice.paidAt ?? new Date();
|
||||
const paidAt = invoice.paidAt ?? input.paidAt ?? new Date();
|
||||
const transitionResult = await transaction.paymentInvoice.updateMany({
|
||||
where: {
|
||||
id: invoice.id,
|
||||
status: "pending",
|
||||
status: {
|
||||
in: allowedSourceStatuses,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: "paid",
|
||||
@@ -209,7 +532,10 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
|
||||
if (currentInvoice.status === "paid") {
|
||||
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
|
||||
return mapInvoice(currentInvoice);
|
||||
return {
|
||||
invoice: mapInvoice(currentInvoice),
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new BillingError(
|
||||
@@ -262,10 +588,11 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
||||
|
||||
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
|
||||
|
||||
return mapInvoice(updatedInvoice);
|
||||
});
|
||||
},
|
||||
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<Prisma.TransactionClient, "$queryRaw">,
|
||||
subscriptionId: string,
|
||||
): Promise<void> {
|
||||
await database.$queryRaw`SELECT 1 FROM "Subscription" WHERE id = ${subscriptionId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
@@ -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<CreatedProviderInvoice>;
|
||||
getInvoiceStatus(providerInvoiceId: string): Promise<ProviderInvoiceStatusRecord>;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user