Files
nroxy/apps/worker/src/main.ts
sirily 9641678fa3 feat: add renewal invoice sweep (#20)
Refs #9

## Summary
- add a worker-side renewal invoice sweep that creates one invoice 72 hours before subscription expiry
- expire elapsed pending invoices automatically and email users when an automatic renewal invoice is created
- stop auto-recreating invoices for the same paid cycle once any invoice already exists for that cycle
- document the current renewal-invoice and pending-invoice expiry behavior

## Testing
- built `infra/docker/web.Dockerfile`
- ran `pnpm --filter @nproxy/db test` inside the built container
- verified `@nproxy/db build` and `@nproxy/web build` during the image build
- built `infra/docker/worker.Dockerfile`

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #20
2026-03-11 12:33:03 +03:00

302 lines
9.2 KiB
TypeScript

import { loadConfig } from "@nproxy/config";
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 invoiceReconciliationBatchSize = 100;
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,
}),
);
setInterval(() => {
void runTick();
}, intervalMs);
void runTick();
process.once("SIGTERM", async () => {
await prisma.$disconnect();
process.exit(0);
});
process.once("SIGINT", async () => {
await prisma.$disconnect();
process.exit(0);
});
async function runTick(): Promise<void> {
if (isTickRunning) {
console.log("worker tick skipped because previous tick is still running");
return;
}
isTickRunning = true;
try {
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 pendingInvoices =
await billingStore.listPendingInvoicesForReconciliation(invoiceReconciliationBatchSize);
const reconciliationSummary = {
polledCount: pendingInvoices.length,
markedPaidCount: 0,
markedExpiredCount: 0,
markedCanceledCount: 0,
alreadyTerminalCount: 0,
ignoredCount: 0,
failedCount: 0,
};
for (const invoice of pendingInvoices) {
try {
const providerInvoice = await paymentProviderAdapter.getInvoiceStatus(invoice.providerInvoiceId);
if (providerInvoice.providerInvoiceId !== invoice.providerInvoiceId) {
reconciliationSummary.failedCount += 1;
console.error(
JSON.stringify({
service: "worker",
event: "invoice_reconciliation_provider_mismatch",
invoiceId: invoice.id,
requestedProviderInvoiceId: invoice.providerInvoiceId,
returnedProviderInvoiceId: providerInvoice.providerInvoiceId,
}),
);
continue;
}
const result = await billingStore.reconcilePendingInvoice({
invoiceId: invoice.id,
providerStatus: providerInvoice.status,
actor: {
type: "system",
ref: "invoice_reconciliation",
},
...(providerInvoice.paidAt ? { paidAt: providerInvoice.paidAt } : {}),
});
switch (result.outcome) {
case "marked_paid":
reconciliationSummary.markedPaidCount += 1;
break;
case "marked_expired":
reconciliationSummary.markedExpiredCount += 1;
break;
case "marked_canceled":
reconciliationSummary.markedCanceledCount += 1;
break;
case "already_paid":
case "already_expired":
case "already_canceled":
reconciliationSummary.alreadyTerminalCount += 1;
break;
case "ignored_terminal_state":
reconciliationSummary.ignoredCount += 1;
break;
case "noop_pending":
break;
}
} catch (error) {
reconciliationSummary.failedCount += 1;
console.error(
JSON.stringify({
service: "worker",
event: "invoice_reconciliation_failed",
invoiceId: invoice.id,
providerInvoiceId: invoice.providerInvoiceId,
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (
reconciliationSummary.polledCount > 0 ||
reconciliationSummary.failedCount > 0 ||
reconciliationSummary.markedPaidCount > 0 ||
reconciliationSummary.markedExpiredCount > 0 ||
reconciliationSummary.markedCanceledCount > 0 ||
reconciliationSummary.alreadyTerminalCount > 0 ||
reconciliationSummary.ignoredCount > 0
) {
console.log(
JSON.stringify({
service: "worker",
event: "pending_invoice_reconciliation",
...reconciliationSummary,
}),
);
}
const expiredInvoices = await billingStore.expireElapsedPendingInvoices();
if (expiredInvoices.expiredCount > 0) {
console.log(
JSON.stringify({
service: "worker",
event: "pending_invoices_expired",
expiredCount: expiredInvoices.expiredCount,
}),
);
}
const recovery = await workerStore.recoverCooldownProviderKeys();
if (recovery.recoveredCount > 0) {
console.log(
JSON.stringify({
service: "worker",
event: "cooldown_keys_recovered",
recoveredCount: recovery.recoveredCount,
}),
);
}
const job = await workerStore.claimNextQueuedGenerationJob();
if (!job) {
console.log(`worker heartbeat interval=${intervalMs} no_queued_jobs=true`);
return;
}
const result = await workerStore.processClaimedGenerationJob(
job,
async (request, providerKey) => {
if (providerKey.providerCode !== config.provider.nanoBananaDefaultModel) {
return {
ok: false as const,
usedProxy: false,
directFallbackUsed: false,
failureKind: "unknown" as const,
providerErrorCode: "unsupported_provider_model",
providerErrorText: `Unsupported provider model: ${providerKey.providerCode}`,
};
}
if (providerKey.proxyBaseUrl) {
const proxyResult = await nanoBananaAdapter.executeGeneration({
request,
providerKey: {
id: providerKey.id,
providerCode: providerKey.providerCode,
label: providerKey.label,
apiKeyLastFour: providerKey.apiKeyLastFour,
},
route: {
kind: "proxy",
proxyBaseUrl: providerKey.proxyBaseUrl,
},
});
if (!proxyResult.ok && proxyResult.failureKind === "transport") {
const directResult = await nanoBananaAdapter.executeGeneration({
request,
providerKey: {
id: providerKey.id,
providerCode: providerKey.providerCode,
label: providerKey.label,
apiKeyLastFour: providerKey.apiKeyLastFour,
},
route: {
kind: "direct",
},
});
return {
...directResult,
usedProxy: true,
directFallbackUsed: true,
};
}
return {
...proxyResult,
usedProxy: true,
directFallbackUsed: false,
};
}
const directResult = await nanoBananaAdapter.executeGeneration({
request,
providerKey: {
id: providerKey.id,
providerCode: providerKey.providerCode,
label: providerKey.label,
apiKeyLastFour: providerKey.apiKeyLastFour,
},
route: {
kind: "direct",
},
});
return {
...directResult,
usedProxy: false,
directFallbackUsed: false,
};
},
);
console.log(JSON.stringify({ service: "worker", event: "job_processed", ...result }));
} catch (error) {
console.error("worker tick failed", error);
} finally {
isTickRunning = false;
}
}