diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index 6777104..3410857 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,20 +1,29 @@ import { loadConfig } from "@nproxy/config"; -import { createPrismaWorkerStore, prisma } from "@nproxy/db"; -import { createNanoBananaSimulatedAdapter } from "@nproxy/providers"; +import { createPrismaBillingStore, createPrismaWorkerStore, prisma } from "@nproxy/db"; +import { + createEmailTransport, + createNanoBananaSimulatedAdapter, + createPaymentProviderAdapter, +} from "@nproxy/providers"; const config = loadConfig(); const intervalMs = config.keyPool.balancePollSeconds * 1000; +const renewalLeadTimeHours = 72; const workerStore = createPrismaWorkerStore(prisma, { cooldownMinutes: config.keyPool.cooldownMinutes, failuresBeforeManualReview: config.keyPool.failuresBeforeManualReview, }); +const billingStore = createPrismaBillingStore(prisma); const nanoBananaAdapter = createNanoBananaSimulatedAdapter(); +const paymentProviderAdapter = createPaymentProviderAdapter(config.payment); +const emailTransport = createEmailTransport(config.email); let isTickRunning = false; console.log( JSON.stringify({ service: "worker", balancePollSeconds: config.keyPool.balancePollSeconds, + renewalLeadTimeHours, providerModel: config.provider.nanoBananaDefaultModel, }), ); @@ -44,6 +53,54 @@ async function runTick(): Promise { isTickRunning = true; 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(); if (recovery.recoveredCount > 0) { diff --git a/docs/ops/payment-system.md b/docs/ops/payment-system.md index d4e147b..b938ce9 100644 --- a/docs/ops/payment-system.md +++ b/docs/ops/payment-system.md @@ -10,8 +10,11 @@ The current payment system covers: - default subscription plan bootstrap - user subscription creation at registration time - 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 - automatic expiry of elapsed subscription periods during account and generation access checks +- automatic expiry of elapsed pending invoices - quota-cycle reset on successful activation 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`. 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 - `pending` does not count as paid. - `pending` does not activate the subscription. @@ -104,6 +118,7 @@ Current runtime note: ## Invoice listing flow - `GET /api/billing/invoices` returns the user's invoices ordered by newest first. - This is a read-only view over persisted `PaymentInvoice` rows. +- The worker also marks `pending` invoices `expired` when `expiresAt` has passed. ## Current activation flow The implemented activation path is manual and admin-driven. diff --git a/packages/db/src/billing-store.test.ts b/packages/db/src/billing-store.test.ts index 2647b6a..7adcdd5 100644 --- a/packages/db/src/billing-store.test.ts +++ b/packages/db/src/billing-store.test.ts @@ -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 | 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>; + existingInvoicesBySubscriptionId?: Record[]>; + expirePendingCount?: number; +}) { + const calls = { + paymentInvoiceCreate: [] as Array>, + paymentInvoiceFindFirst: [] as Array>, + paymentInvoiceExpireUpdateMany: [] as Array>, + }; + + 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 }) => { + 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[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", + }, + }; +} diff --git a/packages/db/src/billing-store.ts b/packages/db/src/billing-store.ts index c8e552c..b1226bc 100644 --- a/packages/db/src/billing-store.ts +++ b/packages/db/src/billing-store.ts @@ -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 { + 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 { + 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); +}