Files
nroxy/packages/db/src/billing-store.test.ts
sirily 9641678fa3 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
2026-03-11 12:33:03 +03:00

687 lines
22 KiB
TypeScript

import test from "node:test";
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",
paidAt: null,
subscription: createSubscriptionFixture(),
});
const database = createBillingDatabase({
invoice,
});
const store = createPrismaBillingStore(database.client);
const result = await store.markInvoicePaid({
invoiceId: invoice.id,
actor: {
type: "web_admin",
ref: "admin_user_1",
},
});
assert.equal(result.status, "paid");
assert.ok(result.paidAt instanceof Date);
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
assert.equal(database.calls.subscriptionUpdate.length, 1);
assert.equal(database.calls.usageLedgerCreate.length, 1);
assert.equal(database.calls.adminAuditCreate.length, 1);
const paymentUpdate = database.calls.paymentInvoiceUpdateMany[0] as ({
data: { status: "paid"; paidAt: Date };
} | undefined);
const auditEntry = database.calls.adminAuditCreate[0];
assert.ok(paymentUpdate);
assert.ok(auditEntry);
assert.equal(paymentUpdate.data.status, "paid");
assert.equal(result.paidAt?.toISOString(), paymentUpdate.data.paidAt.toISOString());
assert.equal(auditEntry.actorType, "web_admin");
assert.equal(auditEntry.actorRef, "admin_user_1");
assert.equal(auditEntry.action, "invoice_mark_paid");
assert.equal(auditEntry.targetType, "payment_invoice");
assert.equal(auditEntry.targetId, invoice.id);
assert.deepEqual(auditEntry.metadata, {
invoiceId: invoice.id,
subscriptionId: invoice.subscriptionId,
provider: invoice.provider,
providerInvoiceId: invoice.providerInvoiceId,
status: "paid",
paidAt: paymentUpdate.data.paidAt.toISOString(),
replayed: false,
});
});
test("markInvoicePaid is idempotent for already paid invoices", async () => {
const paidAt = new Date("2026-03-10T12:00:00.000Z");
const invoice = createInvoiceFixture({
status: "paid",
paidAt,
subscription: createSubscriptionFixture(),
});
const database = createBillingDatabase({
invoice,
});
const store = createPrismaBillingStore(database.client);
const result = await store.markInvoicePaid({
invoiceId: invoice.id,
actor: {
type: "web_admin",
ref: "admin_user_1",
},
});
assert.equal(result.status, "paid");
assert.equal(result.paidAt?.toISOString(), paidAt.toISOString());
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 0);
assert.equal(database.calls.subscriptionUpdate.length, 0);
assert.equal(database.calls.usageLedgerCreate.length, 0);
assert.equal(database.calls.adminAuditCreate.length, 1);
assert.equal(database.calls.adminAuditCreate[0]?.action, "invoice_mark_paid_replayed");
assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true);
});
test("markInvoicePaid rejects invalid terminal invoice transitions", async () => {
const invoice = createInvoiceFixture({
status: "expired",
paidAt: null,
subscription: createSubscriptionFixture(),
});
const database = createBillingDatabase({
invoice,
});
const store = createPrismaBillingStore(database.client);
await assert.rejects(
store.markInvoicePaid({
invoiceId: invoice.id,
actor: {
type: "web_admin",
ref: "admin_user_1",
},
}),
(error: unknown) =>
error instanceof BillingError &&
error.code === "invoice_transition_not_allowed" &&
error.message === 'Invoice in status "expired" cannot be marked paid.',
);
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 0);
assert.equal(database.calls.subscriptionUpdate.length, 0);
assert.equal(database.calls.usageLedgerCreate.length, 0);
assert.equal(database.calls.adminAuditCreate.length, 0);
});
test("markInvoicePaid treats a concurrent pending->paid race as a replay without duplicate side effects", async () => {
const paidAt = new Date("2026-03-10T12:00:00.000Z");
const database = createBillingDatabase({
invoice: createInvoiceFixture({
status: "pending",
paidAt: null,
subscription: createSubscriptionFixture(),
}),
updateManyCount: 0,
invoiceAfterFailedTransition: createInvoiceFixture({
status: "paid",
paidAt,
subscription: createSubscriptionFixture(),
}),
});
const store = createPrismaBillingStore(database.client);
const result = await store.markInvoicePaid({
invoiceId: "invoice_1",
actor: {
type: "web_admin",
ref: "admin_user_1",
},
});
assert.equal(result.status, "paid");
assert.equal(result.paidAt?.toISOString(), paidAt.toISOString());
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
assert.equal(database.calls.subscriptionUpdate.length, 0);
assert.equal(database.calls.usageLedgerCreate.length, 0);
assert.equal(database.calls.adminAuditCreate.length, 1);
assert.equal(database.calls.adminAuditCreate[0]?.action, "invoice_mark_paid_replayed");
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;
invoiceAfterFailedTransition?: ReturnType<typeof createInvoiceFixture>;
}) {
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>>,
};
let currentInvoice = input.invoice;
let findUniqueCallCount = 0;
const transaction = {
paymentInvoice: {
findUnique: async () => {
findUniqueCallCount += 1;
if (
input.invoiceAfterFailedTransition &&
input.updateManyCount === 0 &&
findUniqueCallCount > 1
) {
currentInvoice = input.invoiceAfterFailedTransition;
}
return currentInvoice;
},
updateMany: async ({
where,
data,
}: {
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 && allowedStatuses.includes(currentInvoice.status) ? 1 : 0);
if (count > 0) {
currentInvoice = {
...currentInvoice,
status: data.status,
paidAt: data.paidAt,
};
}
return { count };
},
},
subscription: {
update: async ({ data }: { data: Record<string, unknown> }) => {
calls.subscriptionUpdate.push({ data });
return currentInvoice.subscription;
},
},
usageLedgerEntry: {
create: async ({ data }: { data: Record<string, unknown> }) => {
calls.usageLedgerCreate.push({ data });
return data;
},
},
adminAuditLog: {
create: async ({ data }: { data: Record<string, unknown> }) => {
calls.adminAuditCreate.push(data);
return data;
},
},
};
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];
return {
client,
calls,
};
}
function createInvoiceFixture(input: {
id?: string;
status: "pending" | "paid" | "expired" | "canceled";
paidAt: Date | null;
createdAt?: Date;
subscription: ReturnType<typeof createSubscriptionFixture> | null;
}) {
return {
id: input.id ?? "invoice_1",
userId: "user_1",
subscriptionId: input.subscription?.id ?? null,
provider: "nowpayments",
providerInvoiceId: "provider_invoice_1",
status: input.status,
currency: "USDT",
amountCrypto: new Prisma.Decimal("29"),
amountUsd: new Prisma.Decimal("29"),
paymentAddress: "wallet_1",
expiresAt: new Date("2026-03-11T12:00:00.000Z"),
paidAt: input.paidAt,
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(
overrides?: Partial<{
id: string;
status: "pending_activation" | "active" | "expired";
currentPeriodStart: Date | null;
currentPeriodEnd: Date | null;
activatedAt: Date | null;
}>,
) {
return {
id: overrides?.id ?? "subscription_1",
userId: "user_1",
planId: "plan_1",
status: overrides?.status ?? ("pending_activation" as const),
renewsManually: true,
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"),
plan: {
id: "plan_1",
code: "basic",
displayName: "Basic",
monthlyRequestLimit: 100,
monthlyPriceUsd: new Prisma.Decimal("29"),
billingCurrency: "USDT",
isActive: true,
createdAt: new Date("2026-03-10T11:00:00.000Z"),
updatedAt: new Date("2026-03-10T11:00:00.000Z"),
},
};
}
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",
},
};
}