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:
2026-03-11 12:33:03 +03:00
parent 624c5809b6
commit 9641678fa3
5 changed files with 1137 additions and 188 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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",
},
};
}

View File

@@ -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`;
}

View File

@@ -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",
};
},
};
}