feat: add invoice polling reconciliation
This commit is contained in:
@@ -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];
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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