feat: add renewal invoice sweep

This commit is contained in:
sirily
2026-03-10 18:25:41 +03:00
parent 624c5809b6
commit eb5272d2cb
4 changed files with 410 additions and 11 deletions

View File

@@ -3,6 +3,97 @@ 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"),
}),
},
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");
},
},
renewalLeadTimeHours: 72,
now: new Date("2026-03-09T12:00:00.000Z"),
});
assert.equal(notifications.length, 0);
assert.equal(database.calls.paymentInvoiceCreate.length, 0);
});
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",
@@ -234,12 +325,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 +344,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 +384,104 @@ 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>>,
};
const client = {
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 };
},
},
} 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

@@ -48,6 +48,18 @@ export interface BillingActorMetadata {
ref?: string;
}
export interface ExpirePendingInvoicesResult {
expiredCount: number;
}
export interface RenewalInvoiceNotification {
userId: string;
email: string;
subscriptionId: string;
subscriptionCurrentPeriodEnd: Date;
invoice: BillingInvoiceRecord;
}
export class BillingError extends Error {
constructor(
readonly code: "invoice_not_found" | "invoice_transition_not_allowed",
@@ -147,6 +159,115 @@ 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 existingCycleInvoice = await database.paymentInvoice.findFirst({
where: {
subscriptionId: subscription.id,
createdAt: {
gte: cycleStart,
},
},
orderBy: {
createdAt: "desc",
},
});
if (existingCycleInvoice) {
continue;
}
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 database.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,
},
});
notifications.push({
userId: subscription.userId,
email: subscription.user.email,
subscriptionId: subscription.id,
subscriptionCurrentPeriodEnd: subscription.currentPeriodEnd,
invoice: mapInvoice(invoice),
});
}
return notifications;
},
async markInvoicePaid(input: {
invoiceId: string;
actor?: BillingActorMetadata;
@@ -376,3 +497,7 @@ 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);
}