Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions backend/billing/controller/invoiceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
],
);
Expand Down Expand Up @@ -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 = {
Expand Down
25 changes: 13 additions & 12 deletions backend/billing/domain/InvoiceEmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { InvoiceData, GeneratedInvoice } from "./InvoiceGenerator";
import { formatDate, formatMoney } from './formatting';

export interface EmailConfig {
fromEmail: string;
Expand Down Expand Up @@ -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) => `
Expand All @@ -55,7 +56,7 @@ export class InvoiceEmailService {
return `
<html>
<body style="font-family:Helvetica,Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;">
<div style="background:#4F46E5;padding:24px;border-radius:8px 8px 0 0;">
<div style="background:${invoice.branding.primaryColor ?? '#4F46E5'};padding:24px;border-radius:8px 8px 0 0;">
<h1 style="color:#fff;margin:0;">Invoice ${invoice.invoiceNumber}</h1>
<p style="color:#C7D2FE;margin:4px 0 0;">${this.config.fromName}</p>
</div>
Expand All @@ -67,8 +68,8 @@ export class InvoiceEmailService {
<p style="margin:0;">${this.escapeHtml(invoice.customerName)}</p>
</td>
<td style="vertical-align:top;width:50%;text-align:right;">
<p style="margin:0;"><strong>Date:</strong> ${new Date(invoice.invoiceDate).toLocaleDateString()}</p>
<p style="margin:0;"><strong>Due:</strong> ${new Date(invoice.dueDate).toLocaleDateString()}</p>
<p style="margin:0;"><strong>Date:</strong> ${formatDate(invoice.invoiceDate, invoice.locale)}</p>
<p style="margin:0;"><strong>Due:</strong> ${formatDate(invoice.dueDate, invoice.locale)}</p>
</td>
</tr>
</table>
Expand All @@ -90,7 +91,7 @@ export class InvoiceEmailService {
<tr style="font-size:18px;font-weight:700;"><td style="text-align:right;padding:8px 0;border-top:2px solid #4F46E5;">Total:</td><td style="text-align:right;padding:8px 0;border-top:2px solid #4F46E5;">${fmt(invoice.total)}</td></tr>
</table>
${invoice.notes ? `<p style="color:#6B7280;font-size:13px;">${this.escapeHtml(invoice.notes)}</p>` : ""}
<p style="color:#6B7280;font-size:13px;margin-top:24px;">Please find the PDF invoice attached. Payment is due by ${new Date(invoice.dueDate).toLocaleDateString()}.</p>
<p style="color:#6B7280;font-size:13px;margin-top:24px;">Please find the PDF invoice attached. Payment is due by ${formatDate(invoice.dueDate, invoice.locale)}.</p>
</div>
</body>
</html>`;
Expand All @@ -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");
}

Expand Down
39 changes: 35 additions & 4 deletions backend/billing/domain/InvoiceGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,6 +48,8 @@ export interface InvoiceData {
discount: number;
total: number;
currency: string;
locale: string;
branding: InvoiceBranding;

// Metadata
subscriptionId?: string;
Expand Down Expand Up @@ -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));
Expand All @@ -142,6 +157,8 @@ export class InvoiceGenerator {
discount,
total,
currency,
locale,
branding: { ...params.branding },
subscriptionId: params.subscriptionId,
notes: params.notes,
};
Expand Down Expand Up @@ -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`);

Expand Down Expand Up @@ -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}`;
}
}
14 changes: 14 additions & 0 deletions backend/billing/domain/formatting.ts
Original file line number Diff line number Diff line change
@@ -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));
}
43 changes: 41 additions & 2 deletions backend/services/billing/creditService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
PrepaymentWallet,
TopAccount,
TransferCreditInput,
LowBalanceAlert,
} from './creditTypes';
import { BillingError } from './errors';

Expand Down Expand Up @@ -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<void>;

constructor(opts: { clock?: () => number; onLowBalance?: (alert: LowBalanceAlert) => void | Promise<void> } = {}) {
this.now = opts.clock ?? nowSec;
this.lowBalanceNotifier = opts.onLowBalance;
}

// ─── Account housekeeping ───────────────────────────────────────────────
Expand Down Expand Up @@ -347,6 +351,7 @@ export class CreditService {
accountId: string;
subscriptionId: string;
currency: string;
lowBalanceThreshold?: number;
}): PrepaymentWallet {
const now = this.now();
const wallet: PrepaymentWallet = {
Expand All @@ -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, []);
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions backend/services/billing/creditTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────
Expand Down
15 changes: 14 additions & 1 deletion backend/services/billing/dunningService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const FALLBACK_STRATEGY: RetryStrategy = {
communicationChannels: ['email', 'push'],
};

interface RetryHistoryRecord {
export interface RetryHistoryRecord {
subscriptionId: string;
merchantId: string;
failureType: FailureType;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion backend/services/billing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -72,6 +72,7 @@ export type {
PrepaymentWallet,
TopAccount,
TransferCreditInput,
LowBalanceAlert,
} from './creditTypes';
export type {
IMeteringService,
Expand Down
Loading
Loading