feat: add invoice polling reconciliation

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

View File

@@ -26,6 +26,10 @@ test("createUpcomingRenewalInvoices creates one invoice for subscriptions enteri
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"),
@@ -72,6 +76,10 @@ test("createUpcomingRenewalInvoices does not auto-create another invoice after o
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"),
@@ -240,6 +248,91 @@ test("markInvoicePaid treats a concurrent pending->paid race as a replay without
assert.equal(database.calls.adminAuditCreate[0]?.metadata?.replayed, true);
});
test("reconcilePendingInvoice marks a pending invoice paid using provider paidAt", async () => {
const providerPaidAt = new Date("2026-03-10T12:34:56.000Z");
const database = createBillingDatabase({
invoice: createInvoiceFixture({
status: "pending",
paidAt: null,
subscription: createSubscriptionFixture(),
}),
});
const store = createPrismaBillingStore(database.client);
const result = await store.reconcilePendingInvoice({
invoiceId: "invoice_1",
providerStatus: "paid",
paidAt: providerPaidAt,
actor: {
type: "system",
ref: "invoice_reconciliation",
},
});
assert.equal(result.outcome, "marked_paid");
assert.equal(result.invoice.paidAt?.toISOString(), providerPaidAt.toISOString());
assert.equal(database.calls.paymentInvoiceUpdateMany.length, 1);
assert.equal(
(
database.calls.paymentInvoiceUpdateMany[0] as {
data: { paidAt: Date };
}
).data.paidAt.toISOString(),
providerPaidAt.toISOString(),
);
assert.equal(
(
database.calls.subscriptionUpdate[0] as {
data: { currentPeriodStart: Date };
}
).data.currentPeriodStart.toISOString(),
providerPaidAt.toISOString(),
);
});
test("reconcilePendingInvoice marks a pending invoice expired", async () => {
const database = createBillingDatabase({
invoice: createInvoiceFixture({
status: "pending",
paidAt: null,
subscription: createSubscriptionFixture(),
}),
});
const store = createPrismaBillingStore(database.client);
const result = await store.reconcilePendingInvoice({
invoiceId: "invoice_1",
providerStatus: "expired",
});
assert.equal(result.outcome, "marked_expired");
assert.equal(result.invoice.status, "expired");
assert.equal(database.calls.paymentInvoiceTerminalUpdateMany.length, 1);
assert.equal(database.calls.subscriptionUpdate.length, 0);
assert.equal(database.calls.usageLedgerCreate.length, 0);
});
test("reconcilePendingInvoice does not override an already paid invoice with expired status", async () => {
const paidAt = new Date("2026-03-10T12:00:00.000Z");
const database = createBillingDatabase({
invoice: createInvoiceFixture({
status: "paid",
paidAt,
subscription: createSubscriptionFixture(),
}),
});
const store = createPrismaBillingStore(database.client);
const result = await store.reconcilePendingInvoice({
invoiceId: "invoice_1",
providerStatus: "expired",
});
assert.equal(result.outcome, "ignored_terminal_state");
assert.equal(result.invoice.status, "paid");
assert.equal(database.calls.paymentInvoiceTerminalUpdateMany.length, 0);
});
function createBillingDatabase(input: {
invoice: ReturnType<typeof createInvoiceFixture>;
updateManyCount?: number;
@@ -247,6 +340,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>>,
@@ -315,6 +409,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];

View File

@@ -1,4 +1,4 @@
import type { PaymentProviderAdapter } from "@nproxy/providers";
import type { PaymentProviderAdapter, ProviderInvoiceStatus } from "@nproxy/providers";
import {
Prisma,
type AdminActorType,
@@ -60,6 +60,26 @@ export interface RenewalInvoiceNotification {
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",
@@ -70,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 },
@@ -271,122 +291,296 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
async markInvoicePaid(input: {
invoiceId: string;
actor?: BillingActorMetadata;
paidAt?: Date;
}): Promise<BillingInvoiceRecord> {
return database.$transaction(async (transaction) => {
const invoice = await transaction.paymentInvoice.findUnique({
where: { id: input.invoiceId },
include: {
subscription: {
include: {
plan: true,
},
},
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,
},
});
if (!invoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (invoice.status === "canceled" || invoice.status === "expired") {
throw new BillingError(
"invoice_transition_not_allowed",
`Invoice in status "${invoice.status}" cannot be marked paid.`,
);
}
if (invoice.status === "paid") {
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
return mapInvoice(invoice);
}
const paidAt = invoice.paidAt ?? new Date();
const transitionResult = await transaction.paymentInvoice.updateMany({
where: {
id: invoice.id,
status: "pending",
},
data: {
status: "paid",
paidAt,
},
});
if (transitionResult.count === 0) {
const currentInvoice = await transaction.paymentInvoice.findUnique({
where: { id: input.invoiceId },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!currentInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (currentInvoice.status === "paid") {
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
return mapInvoice(currentInvoice);
}
throw new BillingError(
"invoice_transition_not_allowed",
`Invoice in status "${currentInvoice.status}" cannot be marked paid.`,
);
}
const updatedInvoice = await transaction.paymentInvoice.findUnique({
where: { id: invoice.id },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!updatedInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (invoice.subscription) {
const periodStart = paidAt;
const periodEnd = addDays(periodStart, 30);
await transaction.subscription.update({
where: { id: invoice.subscription.id },
data: {
status: "active",
activatedAt: invoice.subscription.activatedAt ?? paidAt,
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
canceledAt: null,
},
});
await transaction.usageLedgerEntry.create({
data: {
userId: invoice.userId,
entryType: "cycle_reset",
deltaRequests: 0,
cycleStartedAt: periodStart,
cycleEndsAt: periodEnd,
note: `Cycle activated from invoice ${invoice.id}.`,
},
});
}
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
return mapInvoice(updatedInvoice);
},
orderBy: {
createdAt: "asc",
},
take: limit,
});
return invoices
.filter(
(invoice): invoice is typeof invoice & { providerInvoiceId: string } =>
invoice.providerInvoiceId !== null,
)
.map((invoice) => ({
userId: invoice.userId,
providerInvoiceId: invoice.providerInvoiceId,
...mapInvoice(invoice),
}));
},
async reconcilePendingInvoice(input: {
invoiceId: string;
providerStatus: ProviderInvoiceStatus;
paidAt?: Date;
actor?: BillingActorMetadata;
}): Promise<InvoiceReconciliationResult> {
const currentInvoice = await database.paymentInvoice.findUnique({
where: { id: input.invoiceId },
});
if (!currentInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (input.providerStatus === "pending") {
return {
outcome: "noop_pending",
invoice: mapInvoice(currentInvoice),
};
}
if (input.providerStatus === "paid") {
if (currentInvoice.status === "expired" || currentInvoice.status === "canceled") {
return {
outcome: "ignored_terminal_state",
invoice: mapInvoice(currentInvoice),
};
}
try {
const result = await markInvoicePaidInternal(database, {
invoiceId: input.invoiceId,
...(input.actor ? { actor: input.actor } : {}),
...(input.paidAt ? { paidAt: input.paidAt } : {}),
});
return {
outcome: result.replayed ? "already_paid" : "marked_paid",
invoice: result.invoice,
};
} catch (error) {
if (
error instanceof BillingError &&
error.code === "invoice_transition_not_allowed"
) {
const terminalInvoice = await database.paymentInvoice.findUnique({
where: { id: input.invoiceId },
});
if (terminalInvoice) {
return {
outcome: "ignored_terminal_state",
invoice: mapInvoice(terminalInvoice),
};
}
}
throw error;
}
}
const targetStatus = input.providerStatus;
if (currentInvoice.status === "paid") {
return {
outcome: "ignored_terminal_state",
invoice: mapInvoice(currentInvoice),
};
}
if (currentInvoice.status === targetStatus) {
return {
outcome: targetStatus === "expired" ? "already_expired" : "already_canceled",
invoice: mapInvoice(currentInvoice),
};
}
if (currentInvoice.status !== "pending") {
return {
outcome: "ignored_terminal_state",
invoice: mapInvoice(currentInvoice),
};
}
const transitionResult = await database.paymentInvoice.updateMany({
where: {
id: input.invoiceId,
status: "pending",
},
data: {
status: targetStatus,
},
});
const updatedInvoice = await database.paymentInvoice.findUnique({
where: { id: input.invoiceId },
});
if (!updatedInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (transitionResult.count > 0) {
return {
outcome: targetStatus === "expired" ? "marked_expired" : "marked_canceled",
invoice: mapInvoice(updatedInvoice),
};
}
if (updatedInvoice.status === targetStatus) {
return {
outcome: targetStatus === "expired" ? "already_expired" : "already_canceled",
invoice: mapInvoice(updatedInvoice),
};
}
return {
outcome: "ignored_terminal_state",
invoice: mapInvoice(updatedInvoice),
};
},
};
return store;
}
async function markInvoicePaidInternal(
database: PrismaClient,
input: {
invoiceId: string;
actor?: BillingActorMetadata;
paidAt?: Date;
},
): Promise<{ invoice: BillingInvoiceRecord; replayed: boolean }> {
return database.$transaction(async (transaction) => {
const invoice = await transaction.paymentInvoice.findUnique({
where: { id: input.invoiceId },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!invoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (invoice.status === "canceled" || invoice.status === "expired") {
throw new BillingError(
"invoice_transition_not_allowed",
`Invoice in status "${invoice.status}" cannot be marked paid.`,
);
}
if (invoice.status === "paid") {
await writeInvoicePaidAuditLog(transaction, invoice, input.actor, true);
return {
invoice: mapInvoice(invoice),
replayed: true,
};
}
const paidAt = invoice.paidAt ?? input.paidAt ?? new Date();
const transitionResult = await transaction.paymentInvoice.updateMany({
where: {
id: invoice.id,
status: "pending",
},
data: {
status: "paid",
paidAt,
},
});
if (transitionResult.count === 0) {
const currentInvoice = await transaction.paymentInvoice.findUnique({
where: { id: input.invoiceId },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!currentInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (currentInvoice.status === "paid") {
await writeInvoicePaidAuditLog(transaction, currentInvoice, input.actor, true);
return {
invoice: mapInvoice(currentInvoice),
replayed: true,
};
}
throw new BillingError(
"invoice_transition_not_allowed",
`Invoice in status "${currentInvoice.status}" cannot be marked paid.`,
);
}
const updatedInvoice = await transaction.paymentInvoice.findUnique({
where: { id: invoice.id },
include: {
subscription: {
include: {
plan: true,
},
},
},
});
if (!updatedInvoice) {
throw new BillingError("invoice_not_found", "Invoice not found.");
}
if (invoice.subscription) {
const periodStart = paidAt;
const periodEnd = addDays(periodStart, 30);
await transaction.subscription.update({
where: { id: invoice.subscription.id },
data: {
status: "active",
activatedAt: invoice.subscription.activatedAt ?? paidAt,
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
canceledAt: null,
},
});
await transaction.usageLedgerEntry.create({
data: {
userId: invoice.userId,
entryType: "cycle_reset",
deltaRequests: 0,
cycleStartedAt: periodStart,
cycleEndsAt: periodEnd,
note: `Cycle activated from invoice ${invoice.id}.`,
},
});
}
await writeInvoicePaidAuditLog(transaction, updatedInvoice, input.actor, false);
return {
invoice: mapInvoice(updatedInvoice),
replayed: false,
};
});
}
async function writeInvoicePaidAuditLog(

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