diff --git a/backend/billing/controller/invoiceController.ts b/backend/billing/controller/invoiceController.ts
index eedf8ec7..a5c5b59b 100644
--- a/backend/billing/controller/invoiceController.ts
+++ b/backend/billing/controller/invoiceController.ts
@@ -6,7 +6,7 @@
*/
import type { Pool } from "../../shared/db/connectionPool";
-import { InvoiceGenerator, type InvoiceData, type GeneratedInvoice } from "../domain/InvoiceGenerator";
+import { InvoiceGenerator, type InvoiceData, type GeneratedInvoice, type InvoiceBranding } from "../domain/InvoiceGenerator";
import { InvoiceEmailService, type EmailConfig, type EmailResult } from "../domain/InvoiceEmailService";
export interface InvoiceControllerDeps {
@@ -39,6 +39,8 @@ export function createInvoiceController(deps: InvoiceControllerDeps) {
subscriptionId?: string;
notes?: string;
dueInDays?: number;
+ locale?: string;
+ branding?: InvoiceBranding;
},
): Promise<{ success: boolean; data?: { invoice: InvoiceData; pdfBase64: string; fileSize: number }; status?: number; error?: string }> {
try {
@@ -68,6 +70,8 @@ export function createInvoiceController(deps: InvoiceControllerDeps) {
subscriptionId: body.subscriptionId,
notes: body.notes,
dueInDays: body.dueInDays,
+ locale: body.locale,
+ branding: body.branding,
});
const generated = generator.generatePdf(invoice);
@@ -76,14 +80,14 @@ export function createInvoiceController(deps: InvoiceControllerDeps) {
await deps.pool.query(
`INSERT INTO invoices
(invoice_number, customer_name, customer_email, status, subtotal,
- tax_rate, tax_amount, discount, total, currency, pdf_base64,
+ tax_rate, tax_amount, discount, total, currency, pdf_base64, locale, branding,
subscription_id, notes, invoice_date, due_date, created_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
[
invoice.invoiceNumber, invoice.customerName, invoice.customerEmail,
invoice.status, invoice.subtotal, invoice.taxRate, invoice.taxAmount,
invoice.discount, invoice.total, invoice.currency, generated.pdfBase64,
- invoice.subscriptionId ?? null, invoice.notes ?? null,
+ invoice.locale, JSON.stringify(invoice.branding), invoice.subscriptionId ?? null, invoice.notes ?? null,
invoice.invoiceDate, invoice.dueDate, new Date().toISOString(),
],
);
@@ -131,6 +135,8 @@ export function createInvoiceController(deps: InvoiceControllerDeps) {
discount: parseFloat(row.discount),
total: parseFloat(row.total),
currency: row.currency,
+ locale: row.locale ?? "en-US",
+ branding: row.branding ?? {},
};
const generated: GeneratedInvoice = {
diff --git a/backend/billing/domain/InvoiceEmailService.ts b/backend/billing/domain/InvoiceEmailService.ts
index 4c4f475a..c07da79c 100644
--- a/backend/billing/domain/InvoiceEmailService.ts
+++ b/backend/billing/domain/InvoiceEmailService.ts
@@ -6,6 +6,7 @@
*/
import type { InvoiceData, GeneratedInvoice } from "./InvoiceGenerator";
+import { formatDate, formatMoney } from './formatting';
export interface EmailConfig {
fromEmail: string;
@@ -39,7 +40,7 @@ export class InvoiceEmailService {
* Build the HTML email body for an invoice.
*/
buildHtmlBody(invoice: InvoiceData): string {
- const fmt = (n: number) => `$${n.toFixed(2)}`;
+ const fmt = (n: number) => formatMoney(n, invoice.currency, invoice.locale);
const itemsHtml = invoice.items
.map(
(item) => `
@@ -55,7 +56,7 @@ export class InvoiceEmailService {
return `
-
+
Invoice ${invoice.invoiceNumber}
${this.config.fromName}
@@ -67,8 +68,8 @@ export class InvoiceEmailService {
${this.escapeHtml(invoice.customerName)}
- Date: ${new Date(invoice.invoiceDate).toLocaleDateString()}
- Due: ${new Date(invoice.dueDate).toLocaleDateString()}
+ Date: ${formatDate(invoice.invoiceDate, invoice.locale)}
+ Due: ${formatDate(invoice.dueDate, invoice.locale)}
|
@@ -90,7 +91,7 @@ export class InvoiceEmailService {
| Total: | ${fmt(invoice.total)} |
${invoice.notes ? `
${this.escapeHtml(invoice.notes)}
` : ""}
-
Please find the PDF invoice attached. Payment is due by ${new Date(invoice.dueDate).toLocaleDateString()}.
+
Please find the PDF invoice attached. Payment is due by ${formatDate(invoice.dueDate, invoice.locale)}.
`;
@@ -103,19 +104,19 @@ export class InvoiceEmailService {
const lines = [
`Invoice ${invoice.invoiceNumber}`,
`From: ${this.config.fromName}`,
- `Date: ${new Date(invoice.invoiceDate).toLocaleDateString()}`,
- `Due: ${new Date(invoice.dueDate).toLocaleDateString()}`,
+ `Date: ${formatDate(invoice.invoiceDate, invoice.locale)}`,
+ `Due: ${formatDate(invoice.dueDate, invoice.locale)}`,
"",
`Bill To: ${invoice.customerName}`,
"",
"Items:",
- ...invoice.items.map((i) => ` ${i.description} x${i.quantity} - $${i.total.toFixed(2)}`),
+ ...invoice.items.map((i) => ` ${i.description} x${i.quantity} - ${formatMoney(i.total, invoice.currency, invoice.locale)}`),
"",
- `Subtotal: $${invoice.subtotal.toFixed(2)}`,
+ `Subtotal: ${formatMoney(invoice.subtotal, invoice.currency, invoice.locale)}`,
];
- if (invoice.discount > 0) lines.push(`Discount: -$${invoice.discount.toFixed(2)}`);
- if (invoice.taxAmount > 0) lines.push(`Tax: $${invoice.taxAmount.toFixed(2)}`);
- lines.push(`Total: $${invoice.total.toFixed(2)}`, "", "PDF invoice attached.", `Payment due by ${new Date(invoice.dueDate).toLocaleDateString()}.`);
+ if (invoice.discount > 0) lines.push(`Discount: -${formatMoney(invoice.discount, invoice.currency, invoice.locale)}`);
+ if (invoice.taxAmount > 0) lines.push(`Tax: ${formatMoney(invoice.taxAmount, invoice.currency, invoice.locale)}`);
+ lines.push(`Total: ${formatMoney(invoice.total, invoice.currency, invoice.locale)}`, "", "PDF invoice attached.", `Payment due by ${formatDate(invoice.dueDate, invoice.locale)}.`);
return lines.join("\n");
}
diff --git a/backend/billing/domain/InvoiceGenerator.ts b/backend/billing/domain/InvoiceGenerator.ts
index fc443186..dc58a64d 100644
--- a/backend/billing/domain/InvoiceGenerator.ts
+++ b/backend/billing/domain/InvoiceGenerator.ts
@@ -7,6 +7,14 @@
* Closes #1132
*/
+import { formatDate, formatMoney } from './formatting';
+
+export interface InvoiceBranding {
+ logoUrl?: string;
+ primaryColor?: string;
+ accentColor?: string;
+}
+
export interface InvoiceLineItem {
description: string;
quantity: number;
@@ -40,6 +48,8 @@ export interface InvoiceData {
discount: number;
total: number;
currency: string;
+ locale: string;
+ branding: InvoiceBranding;
// Metadata
subscriptionId?: string;
@@ -114,10 +124,15 @@ export class InvoiceGenerator {
subscriptionId?: string;
notes?: string;
dueInDays?: number;
+ locale?: string;
+ branding?: InvoiceBranding;
+ senderAddress?: string;
+ customerAddress?: string;
}): InvoiceData {
const taxRate = params.taxRate ?? 0;
const discount = params.discount ?? 0;
- const currency = params.currency ?? "USD";
+ const currency = (params.currency ?? "USD").toUpperCase();
+ const locale = params.locale ?? "en-US";
const now = new Date();
const dueDate = new Date(now);
dueDate.setDate(dueDate.getDate() + (params.dueInDays ?? 30));
@@ -142,6 +157,8 @@ export class InvoiceGenerator {
discount,
total,
currency,
+ locale,
+ branding: { ...params.branding },
subscriptionId: params.subscriptionId,
notes: params.notes,
};
@@ -173,19 +190,26 @@ export class InvoiceGenerator {
const content: string[] = [];
// Build text content
- const fmt = (n: number) => `${invoice.currency === "USD" ? "$" : ""}${n.toFixed(2)}`;
+ const fmt = (n: number) => formatMoney(n, invoice.currency, invoice.locale);
+ const primaryColor = this.pdfColor(invoice.branding.primaryColor ?? '#4F46E5');
content.push(`BT`);
content.push(`/F1 24 Tf`);
content.push(`72 720 Td`);
+ content.push(`${primaryColor} rg`);
content.push(`(INVOICE) Tj`);
+ content.push(`0 0 0 rg`);
+ if (invoice.branding.logoUrl) {
+ content.push(`0 -18 Td`);
+ content.push(`(${this.escapePdfString(`Logo: ${invoice.branding.logoUrl}`)}) Tj`);
+ }
content.push(`/F1 12 Tf`);
content.push(`0 -30 Td`);
content.push(`(${this.escapePdfString(`Invoice #: ${invoice.invoiceNumber}`)}) Tj`);
content.push(`0 -18 Td`);
- content.push(`(${this.escapePdfString(`Date: ${new Date(invoice.invoiceDate).toLocaleDateString()}`)}) Tj`);
+ content.push(`(${this.escapePdfString(`Date: ${formatDate(invoice.invoiceDate, invoice.locale)}`)}) Tj`);
content.push(`0 -18 Td`);
- content.push(`(${this.escapePdfString(`Due: ${new Date(invoice.dueDate).toLocaleDateString()}`)}) Tj`);
+ content.push(`(${this.escapePdfString(`Due: ${formatDate(invoice.dueDate, invoice.locale)}`)}) Tj`);
content.push(`0 -18 Td`);
content.push(`(${this.escapePdfString(`Status: ${invoice.status}`)}) Tj`);
@@ -287,4 +311,11 @@ export class InvoiceGenerator {
private escapePdfString(str: string): string {
return str.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
}
+
+ private pdfColor(hex: string): string {
+ const match = /^#?([0-9a-f]{6})$/i.exec(hex);
+ if (!match) return '0.31 0.27 0.90';
+ const value = match[1];
+ return `${parseInt(value.slice(0, 2), 16) / 255} ${parseInt(value.slice(2, 4), 16) / 255} ${parseInt(value.slice(4), 16) / 255}`;
+ }
}
diff --git a/backend/billing/domain/formatting.ts b/backend/billing/domain/formatting.ts
new file mode 100644
index 00000000..1f760189
--- /dev/null
+++ b/backend/billing/domain/formatting.ts
@@ -0,0 +1,14 @@
+/** Shared invoice formatting so PDFs, email and API representations agree. */
+export function formatMoney(amount: number, currency: string, locale = 'en-US'): string {
+ return new Intl.NumberFormat(locale || 'en-US', {
+ style: 'currency',
+ currency: (currency || 'USD').toUpperCase(),
+ currencyDisplay: 'symbol',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(Number.isFinite(amount) ? amount : 0);
+}
+
+export function formatDate(value: string, locale = 'en-US'): string {
+ return new Intl.DateTimeFormat(locale || 'en-US').format(new Date(value));
+}
diff --git a/backend/services/billing/creditService.ts b/backend/services/billing/creditService.ts
index 00c64d87..7474d20e 100644
--- a/backend/services/billing/creditService.ts
+++ b/backend/services/billing/creditService.ts
@@ -50,6 +50,7 @@ import type {
PrepaymentWallet,
TopAccount,
TransferCreditInput,
+ LowBalanceAlert,
} from './creditTypes';
import { BillingError } from './errors';
@@ -98,8 +99,11 @@ export class CreditService {
private nextTxId = 1;
private readonly now: () => number;
- constructor(opts: { clock?: () => number } = {}) {
+ private readonly lowBalanceNotifier?: (alert: LowBalanceAlert) => void | Promise;
+
+ constructor(opts: { clock?: () => number; onLowBalance?: (alert: LowBalanceAlert) => void | Promise } = {}) {
this.now = opts.clock ?? nowSec;
+ this.lowBalanceNotifier = opts.onLowBalance;
}
// ─── Account housekeeping ───────────────────────────────────────────────
@@ -347,6 +351,7 @@ export class CreditService {
accountId: string;
subscriptionId: string;
currency: string;
+ lowBalanceThreshold?: number;
}): PrepaymentWallet {
const now = this.now();
const wallet: PrepaymentWallet = {
@@ -359,6 +364,7 @@ export class CreditService {
totalWithdrawn: 0,
createdAt: now,
updatedAt: now,
+ lowBalanceThreshold: input.lowBalanceThreshold,
};
this.wallets.set(wallet.id, wallet);
this.walletTx.set(wallet.id, []);
@@ -388,6 +394,7 @@ export class CreditService {
const txs = this.walletTx.get(wallet.id) ?? [];
txs.push(tx);
this.walletTx.set(wallet.id, txs);
+ this.notifyIfLow(wallet);
return { ...wallet };
}
@@ -438,7 +445,10 @@ export class CreditService {
throw BillingError.walletNotFound(input.walletId);
}
const drawdownAmount = Math.min(wallet.balance, input.amount);
- if (drawdownAmount <= 0) return { ...wallet };
+ if (drawdownAmount <= 0) {
+ this.notifyIfLow(wallet);
+ return { ...wallet };
+ }
const now = this.now();
wallet.balance -= drawdownAmount;
wallet.totalWithdrawn += drawdownAmount;
@@ -455,9 +465,38 @@ export class CreditService {
const txs = this.walletTx.get(wallet.id) ?? [];
txs.push(tx);
this.walletTx.set(wallet.id, txs);
+ this.notifyIfLow(wallet);
return { ...wallet };
}
+ /** Apply prepaid funds before any card or crypto gateway is called. */
+ applyPrepaymentBeforeCharge(input: {
+ walletId: number;
+ accountId: string;
+ invoiceId: string;
+ amountDue: number;
+ }): { applied: number; remainingDue: number; wallet: PrepaymentWallet } {
+ if (input.amountDue < 0) throw BillingError.creditInvalidAmount('amountDue may not be negative');
+ const before = this.getWallet(input.walletId);
+ if (!before || before.accountId !== input.accountId) throw BillingError.walletNotFound(input.walletId);
+ const wallet = this.drawdown(input);
+ return { applied: before.balance - wallet.balance, remainingDue: input.amountDue - (before.balance - wallet.balance), wallet };
+ }
+
+ private notifyIfLow(wallet: PrepaymentWallet): void {
+ const threshold = wallet.lowBalanceThreshold;
+ if (threshold !== undefined && wallet.balance <= threshold && this.lowBalanceNotifier) {
+ void this.lowBalanceNotifier({
+ walletId: wallet.id,
+ accountId: wallet.accountId,
+ subscriptionId: wallet.subscriptionId,
+ balance: wallet.balance,
+ threshold,
+ currency: wallet.currency,
+ });
+ }
+ }
+
getWallet(walletId: number): PrepaymentWallet | undefined {
const w = this.wallets.get(walletId);
return w ? { ...w } : undefined;
diff --git a/backend/services/billing/creditTypes.ts b/backend/services/billing/creditTypes.ts
index fca33fa1..eea31b61 100644
--- a/backend/services/billing/creditTypes.ts
+++ b/backend/services/billing/creditTypes.ts
@@ -81,6 +81,16 @@ export interface PrepaymentWallet {
totalWithdrawn: number;
createdAt: number;
updatedAt: number;
+ lowBalanceThreshold?: number;
+}
+
+export interface LowBalanceAlert {
+ walletId: number;
+ accountId: string;
+ subscriptionId: string;
+ balance: number;
+ threshold: number;
+ currency: string;
}
// ─── Operation inputs / outputs ─────────────────────────────────────────────
diff --git a/backend/services/billing/dunningService.ts b/backend/services/billing/dunningService.ts
index 1e3f5f2b..ad639b12 100644
--- a/backend/services/billing/dunningService.ts
+++ b/backend/services/billing/dunningService.ts
@@ -126,7 +126,7 @@ const FALLBACK_STRATEGY: RetryStrategy = {
communicationChannels: ['email', 'push'],
};
-interface RetryHistoryRecord {
+export interface RetryHistoryRecord {
subscriptionId: string;
merchantId: string;
failureType: FailureType;
@@ -396,6 +396,12 @@ export class DunningService {
this.progressiveEngine.trackStageEntry(entry, now_ts);
this.sendCommunication(entry, nextStage, strategy);
} else {
+ // Give the subscriber one explicit warning before the cancellation
+ // action is scheduled. The communication is also retained in the
+ // per-subscription log for auditability.
+ const warningStage = stages.find((s) => s.stage === 'warn') ??
+ DEFAULT_DUNNING_STAGES.find((s) => s.stage === 'warn');
+ if (warningStage) this.sendCommunication(entry, warningStage, strategy);
entry.currentStage = 'cancel';
entry.failedAttempts = 0;
delayHours = 24;
@@ -453,6 +459,13 @@ export class DunningService {
return this.entries.get(subscriptionId);
}
+ /** Immutable copy of every failed and successful retry attempt. */
+ getRetryHistory(merchantId?: string): RetryHistoryRecord[] {
+ return this.retryHistory
+ .filter((attempt) => !merchantId || attempt.merchantId === merchantId)
+ .map((attempt) => ({ ...attempt }));
+ }
+
listActiveDunning(merchantId?: string): DunningEntry[] {
const all = Array.from(this.entries.values());
if (merchantId) {
diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts
index 700c9432..89820fab 100644
--- a/backend/services/billing/index.ts
+++ b/backend/services/billing/index.ts
@@ -28,7 +28,7 @@ export type {
TaxRemittanceReportRequest,
} from './taxTypes';
export { DunningService, dunningService } from './dunningService';
-export type { FailureType, RetryScheduleConfig, RetryAnalytics } from './dunningService';
+export type { FailureType, RetryScheduleConfig, RetryAnalytics, RetryHistoryRecord } from './dunningService';
export { ProrationService, prorationService } from './proration';
export type {
ProrationConfiguration,
@@ -72,6 +72,7 @@ export type {
PrepaymentWallet,
TopAccount,
TransferCreditInput,
+ LowBalanceAlert,
} from './creditTypes';
export type {
IMeteringService,
diff --git a/db/migrations/014_invoice_localization_branding.sql b/db/migrations/014_invoice_localization_branding.sql
new file mode 100644
index 00000000..26ef5a6a
--- /dev/null
+++ b/db/migrations/014_invoice_localization_branding.sql
@@ -0,0 +1,11 @@
+-- Invoice presentation is immutable per invoice so historical PDFs remain stable.
+BEGIN;
+ALTER TABLE invoices ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en-US';
+ALTER TABLE invoices ADD COLUMN IF NOT EXISTS branding JSONB NOT NULL DEFAULT '{}'::jsonb;
+COMMIT;
+
+-- @down
+BEGIN;
+ALTER TABLE invoices DROP COLUMN IF EXISTS branding;
+ALTER TABLE invoices DROP COLUMN IF EXISTS locale;
+COMMIT;
diff --git a/db/migrations/015_billing_retry_and_prepayment.sql b/db/migrations/015_billing_retry_and_prepayment.sql
new file mode 100644
index 00000000..35ebe3b6
--- /dev/null
+++ b/db/migrations/015_billing_retry_and_prepayment.sql
@@ -0,0 +1,44 @@
+-- Durable audit records for payment retries and customer prepaid balances.
+BEGIN;
+CREATE TABLE IF NOT EXISTS payment_retry_attempts (
+ id BIGSERIAL PRIMARY KEY,
+ subscription_id TEXT NOT NULL,
+ merchant_id TEXT NOT NULL,
+ failure_type TEXT NOT NULL,
+ attempt INTEGER NOT NULL,
+ success BOOLEAN NOT NULL DEFAULT false,
+ delay_hours NUMERIC NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_payment_retry_subscription ON payment_retry_attempts(subscription_id, created_at);
+
+CREATE TABLE IF NOT EXISTS customer_prepayment_wallets (
+ id BIGSERIAL PRIMARY KEY,
+ customer_id TEXT NOT NULL,
+ subscription_id TEXT NOT NULL,
+ currency TEXT NOT NULL,
+ balance NUMERIC(20, 8) NOT NULL DEFAULT 0,
+ low_balance_threshold NUMERIC(20, 8),
+ total_deposited NUMERIC(20, 8) NOT NULL DEFAULT 0,
+ total_withdrawn NUMERIC(20, 8) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(customer_id, subscription_id, currency)
+);
+CREATE TABLE IF NOT EXISTS customer_prepayment_transactions (
+ id BIGSERIAL PRIMARY KEY,
+ wallet_id BIGINT NOT NULL REFERENCES customer_prepayment_wallets(id),
+ kind TEXT NOT NULL CHECK (kind IN ('deposit', 'withdraw', 'drawdown')),
+ amount NUMERIC(20, 8) NOT NULL,
+ balance_after NUMERIC(20, 8) NOT NULL,
+ invoice_id TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+COMMIT;
+
+-- @down
+BEGIN;
+DROP TABLE IF EXISTS customer_prepayment_transactions;
+DROP TABLE IF EXISTS customer_prepayment_wallets;
+DROP TABLE IF EXISTS payment_retry_attempts;
+COMMIT;