feat: add renewal invoice sweep
This commit is contained in:
@@ -1,20 +1,29 @@
|
|||||||
import { loadConfig } from "@nproxy/config";
|
import { loadConfig } from "@nproxy/config";
|
||||||
import { createPrismaWorkerStore, prisma } from "@nproxy/db";
|
import { createPrismaBillingStore, createPrismaWorkerStore, prisma } from "@nproxy/db";
|
||||||
import { createNanoBananaSimulatedAdapter } from "@nproxy/providers";
|
import {
|
||||||
|
createEmailTransport,
|
||||||
|
createNanoBananaSimulatedAdapter,
|
||||||
|
createPaymentProviderAdapter,
|
||||||
|
} from "@nproxy/providers";
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const intervalMs = config.keyPool.balancePollSeconds * 1000;
|
const intervalMs = config.keyPool.balancePollSeconds * 1000;
|
||||||
|
const renewalLeadTimeHours = 72;
|
||||||
const workerStore = createPrismaWorkerStore(prisma, {
|
const workerStore = createPrismaWorkerStore(prisma, {
|
||||||
cooldownMinutes: config.keyPool.cooldownMinutes,
|
cooldownMinutes: config.keyPool.cooldownMinutes,
|
||||||
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
|
failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview,
|
||||||
});
|
});
|
||||||
|
const billingStore = createPrismaBillingStore(prisma);
|
||||||
const nanoBananaAdapter = createNanoBananaSimulatedAdapter();
|
const nanoBananaAdapter = createNanoBananaSimulatedAdapter();
|
||||||
|
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
||||||
|
const emailTransport = createEmailTransport(config.email);
|
||||||
let isTickRunning = false;
|
let isTickRunning = false;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
service: "worker",
|
service: "worker",
|
||||||
balancePollSeconds: config.keyPool.balancePollSeconds,
|
balancePollSeconds: config.keyPool.balancePollSeconds,
|
||||||
|
renewalLeadTimeHours,
|
||||||
providerModel: config.provider.nanoBananaDefaultModel,
|
providerModel: config.provider.nanoBananaDefaultModel,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -44,6 +53,54 @@ async function runTick(): Promise<void> {
|
|||||||
isTickRunning = true;
|
isTickRunning = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const expiredInvoices = await billingStore.expireElapsedPendingInvoices();
|
||||||
|
|
||||||
|
if (expiredInvoices.expiredCount > 0) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
service: "worker",
|
||||||
|
event: "pending_invoices_expired",
|
||||||
|
expiredCount: expiredInvoices.expiredCount,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recovery = await workerStore.recoverCooldownProviderKeys();
|
const recovery = await workerStore.recoverCooldownProviderKeys();
|
||||||
|
|
||||||
if (recovery.recoveredCount > 0) {
|
if (recovery.recoveredCount > 0) {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ The current payment system covers:
|
|||||||
- default subscription plan bootstrap
|
- default subscription plan bootstrap
|
||||||
- user subscription creation at registration time
|
- user subscription creation at registration time
|
||||||
- invoice creation through a provider adapter
|
- 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
|
||||||
- manual admin activation after an operator verifies that the provider reported a final successful payment status
|
- 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 subscription periods during account and generation access checks
|
||||||
|
- automatic expiry of elapsed pending invoices
|
||||||
- quota-cycle reset on successful activation
|
- quota-cycle reset on successful activation
|
||||||
|
|
||||||
The current payment system does not yet cover:
|
The current payment system does not yet cover:
|
||||||
@@ -94,6 +97,17 @@ Current runtime note:
|
|||||||
5. The returned provider invoice data is persisted as a new local `PaymentInvoice` in `pending`.
|
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.
|
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
|
## Payment status semantics
|
||||||
- `pending` does not count as paid.
|
- `pending` does not count as paid.
|
||||||
- `pending` does not activate the subscription.
|
- `pending` does not activate the subscription.
|
||||||
@@ -104,6 +118,7 @@ Current runtime note:
|
|||||||
## Invoice listing flow
|
## Invoice listing flow
|
||||||
- `GET /api/billing/invoices` returns the user's invoices ordered by newest first.
|
- `GET /api/billing/invoices` returns the user's invoices ordered by newest first.
|
||||||
- This is a read-only view over persisted `PaymentInvoice` rows.
|
- This is a read-only view over persisted `PaymentInvoice` rows.
|
||||||
|
- The worker also marks `pending` invoices `expired` when `expiresAt` has passed.
|
||||||
|
|
||||||
## Current activation flow
|
## Current activation flow
|
||||||
The implemented activation path is manual and admin-driven.
|
The implemented activation path is manual and admin-driven.
|
||||||
|
|||||||
@@ -3,6 +3,97 @@ import assert from "node:assert/strict";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { BillingError, createPrismaBillingStore } from "./billing-store.js";
|
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 () => {
|
test("markInvoicePaid activates a pending invoice once and writes an admin audit log", async () => {
|
||||||
const invoice = createInvoiceFixture({
|
const invoice = createInvoiceFixture({
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -234,12 +325,14 @@ function createBillingDatabase(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createInvoiceFixture(input: {
|
function createInvoiceFixture(input: {
|
||||||
|
id?: string;
|
||||||
status: "pending" | "paid" | "expired" | "canceled";
|
status: "pending" | "paid" | "expired" | "canceled";
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
createdAt?: Date;
|
||||||
subscription: ReturnType<typeof createSubscriptionFixture> | null;
|
subscription: ReturnType<typeof createSubscriptionFixture> | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: "invoice_1",
|
id: input.id ?? "invoice_1",
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
subscriptionId: input.subscription?.id ?? null,
|
subscriptionId: input.subscription?.id ?? null,
|
||||||
provider: "nowpayments",
|
provider: "nowpayments",
|
||||||
@@ -251,22 +344,30 @@ function createInvoiceFixture(input: {
|
|||||||
paymentAddress: "wallet_1",
|
paymentAddress: "wallet_1",
|
||||||
expiresAt: new Date("2026-03-11T12:00:00.000Z"),
|
expiresAt: new Date("2026-03-11T12:00:00.000Z"),
|
||||||
paidAt: input.paidAt,
|
paidAt: input.paidAt,
|
||||||
createdAt: new Date("2026-03-10T11:00:00.000Z"),
|
createdAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"),
|
||||||
updatedAt: new Date("2026-03-10T11:00:00.000Z"),
|
updatedAt: input.createdAt ?? new Date("2026-03-10T11:00:00.000Z"),
|
||||||
subscription: input.subscription,
|
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 {
|
return {
|
||||||
id: "subscription_1",
|
id: overrides?.id ?? "subscription_1",
|
||||||
userId: "user_1",
|
userId: "user_1",
|
||||||
planId: "plan_1",
|
planId: "plan_1",
|
||||||
status: "pending_activation" as const,
|
status: overrides?.status ?? ("pending_activation" as const),
|
||||||
renewsManually: true,
|
renewsManually: true,
|
||||||
activatedAt: null,
|
activatedAt: overrides?.activatedAt ?? null,
|
||||||
currentPeriodStart: null,
|
currentPeriodStart: overrides?.currentPeriodStart ?? null,
|
||||||
currentPeriodEnd: null,
|
currentPeriodEnd: overrides?.currentPeriodEnd ?? null,
|
||||||
canceledAt: null,
|
canceledAt: null,
|
||||||
createdAt: new Date("2026-03-10T11:00:00.000Z"),
|
createdAt: new Date("2026-03-10T11:00:00.000Z"),
|
||||||
updatedAt: 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ export interface BillingActorMetadata {
|
|||||||
ref?: string;
|
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 {
|
export class BillingError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
readonly code: "invoice_not_found" | "invoice_transition_not_allowed",
|
readonly code: "invoice_not_found" | "invoice_transition_not_allowed",
|
||||||
@@ -147,6 +159,115 @@ export function createPrismaBillingStore(database: PrismaClient = defaultPrisma)
|
|||||||
return mapInvoice(invoice);
|
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: {
|
async markInvoicePaid(input: {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
actor?: BillingActorMetadata;
|
actor?: BillingActorMetadata;
|
||||||
@@ -376,3 +497,7 @@ function mapSubscription(subscription: {
|
|||||||
function addDays(value: Date, days: number): Date {
|
function addDays(value: Date, days: number): Date {
|
||||||
return new Date(value.getTime() + days * 24 * 60 * 60 * 1000);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user