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;