From 7d5e4d6a445bb19d550bbf1a26d011e34e5309d9 Mon Sep 17 00:00:00 2001 From: damiedee96 <1.67187208e+08+damiedee96@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:21:55 +0000 Subject: [PATCH] feat: add invoice line-item metadata --- .../billing/invoiceCustomizationService.ts | 6 +- contracts/invoice/src/lib.rs | 11 ++++ contracts/invoice/src/pdf.rs | 8 ++- contracts/types/src/lib.rs | 4 +- src/services/invoiceService.ts | 25 +++++++- src/store/invoiceStore.ts | 16 ++++- src/types/invoice.ts | 4 ++ src/utils/__tests__/invoicePrint.test.ts | 29 ++++++++- src/utils/invoice.ts | 61 +++++++++++++++++-- 9 files changed, 150 insertions(+), 14 deletions(-) diff --git a/backend/services/billing/invoiceCustomizationService.ts b/backend/services/billing/invoiceCustomizationService.ts index b51e1d03..23f740de 100644 --- a/backend/services/billing/invoiceCustomizationService.ts +++ b/backend/services/billing/invoiceCustomizationService.ts @@ -143,9 +143,12 @@ function renderLineItems(items: InvoiceLineItem[], currency: string, branding: I .map((item) => { const amount = formatMoney(Number(item.lineTotal ?? 0), currency); const quantity = item.quantity ?? 1; + const metadata = Object.entries(item.metadata ?? {}) + .map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(value)}
`) + .join(''); return [ '', - `${escapeHtml(item.description ?? 'Item')}`, + `${escapeHtml(item.description ?? 'Item')}${metadata}`, `${escapeHtml(quantity)}`, `${escapeHtml(amount)}`, '', @@ -227,6 +230,7 @@ export class InvoiceCustomizationService { 'th,td{padding:8px 12px;text-align:left}', '.num{text-align:right}', '.empty{color:#5f6368;font-style:italic}', + '.line-metadata{font-size:11px;color:#5f6368;margin-top:3px}', `.total{font-weight:700;color:${branding.accentColor}}`, 'footer{margin-top:32px;font-size:12px;color:#5f6368}', '', diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 7dc58563..21d7ff35 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -372,6 +372,7 @@ fn build_line_item( let unit_price = convert_amount(plan.price, exchange_rate, config.exchange_rate_scale); InvoiceLineItem { description: plan.name.clone(), + metadata: soroban_sdk::Map::new(env), quantity: 1, unit_price, currency: if invoice_currency.is_empty() { @@ -392,6 +393,13 @@ fn validate_invoice_period(period: &TimeRange) { ); } +fn validate_line_item_metadata(item: &InvoiceLineItem) { + assert!(item.metadata.len() <= 50, "Line item metadata has too many entries"); + for (key, value) in item.metadata.iter() { + assert!(key.len() > 0 && key.len() <= 100, "Invalid line item metadata key"); + assert!(value.len() <= 500, "Line item metadata value is too long"); + } +} fn validate_invoice_amounts(invoice: &Invoice) { assert!(invoice.subtotal >= 0, "Invoice subtotal cannot be negative"); assert!(invoice.tax >= 0, "Invoice tax cannot be negative"); @@ -413,6 +421,9 @@ fn store_invoice(env: &Env, invoice: &Invoice) { !invoice.line_items.is_empty(), "Invoice must contain at least one line item" ); + for item in invoice.line_items.iter() { + validate_line_item_metadata(&item); + } storage_persistent_set(env, StorageKey::Invoice(invoice.id), invoice.clone()); let mut list: Vec = storage_instance_get( diff --git a/contracts/invoice/src/pdf.rs b/contracts/invoice/src/pdf.rs index fac1cbad..622b5600 100644 --- a/contracts/invoice/src/pdf.rs +++ b/contracts/invoice/src/pdf.rs @@ -9,13 +9,17 @@ use crate::to_host_string; fn line_item_text(item: &InvoiceLineItem) -> StdString { format!( - "{} | qty {} | unit {} {} | total {} | tax {} bps", + "{} | qty {} | unit {} {} | total {} | tax {} bps{}", to_host_string(&item.description), item.quantity, item.unit_price, to_host_string(&item.currency), item.line_total, - item.tax_rate_bps + item.tax_rate_bps, + item.metadata + .iter() + .map(|(key, value)| format!(" | {}={}", to_host_string(&key), to_host_string(&value))) + .collect::() ) } diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 5a9c2a5a..4f86f906 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -7,7 +7,7 @@ pub mod errors; pub use errors::CoreError; -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec}; /// Billing interval in seconds. #[contracttype] @@ -62,6 +62,8 @@ pub struct TimeRange { #[derive(Clone, Debug, PartialEq)] pub struct InvoiceLineItem { pub description: String, + /// Arbitrary string key/value references associated with this line. + pub metadata: Map, pub quantity: u32, pub unit_price: i128, pub currency: String, diff --git a/src/services/invoiceService.ts b/src/services/invoiceService.ts index 07e7a515..83fbcc50 100644 --- a/src/services/invoiceService.ts +++ b/src/services/invoiceService.ts @@ -13,6 +13,7 @@ import type { import { InvoiceLayout, InvoiceStatus } from '../types/invoice'; import { getNextSequence, generateLegalInvoiceNumber } from './sequenceService'; import { calculateInvoiceTotals, resolveLineTotal } from '../utils/invoiceTotals'; +import { validateInvoiceLineItemMetadata } from '../utils/invoice'; const STORAGE_KEYS = { INVOICES: '@SubTrackr:invoices', @@ -20,6 +21,14 @@ const STORAGE_KEYS = { TEMPLATES: '@SubTrackr:invoiceTemplates', } as const; +const escapeHtmlForInvoice = (value: unknown): string => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // Branding Management export async function saveBranding( branding: Omit @@ -149,6 +158,10 @@ export async function createInvoice(data: InvoiceFormData): Promise { const now = new Date(); // Accepts either the itemised shape (lineTotal) or the flat one (amount). + const metadataErrors = (data.lineItems ?? []).flatMap((item, index) => + validateInvoiceLineItemMetadata(item.metadata, `line item ${index + 1} metadata`) + ); + if (metadataErrors.length > 0) throw new Error(metadataErrors.join('; ')); const lineItems = (data.lineItems ?? []).map((item) => ({ ...item, lineTotal: resolveLineTotal(item), @@ -220,6 +233,10 @@ export async function updateInvoice(id: string, updates: Partial): Prom // Recalculate totals if line items changed. Normalise the flat `amount` // alias onto `lineTotal` and keep the total aliases in sync. if (updates.lineItems) { + const metadataErrors = updates.lineItems.flatMap((item, index) => + validateInvoiceLineItemMetadata(item.metadata, `line item ${index + 1} metadata`) + ); + if (metadataErrors.length > 0) throw new Error(metadataErrors.join('; ')); const lineItems = updates.lineItems.map((item) => ({ ...item, lineTotal: resolveLineTotal(item), @@ -470,7 +487,13 @@ async function generateInvoiceHTML( ${item.description} ${item.quantity} ${invoice.currency} ${item.unitPrice.toFixed(2)} - ${invoice.currency} ${item.lineTotal.toFixed(2)} + ${invoice.currency} ${(item.lineTotal ?? item.amount ?? 0).toFixed(2)} + ${Object.entries(item.metadata ?? {}) + .map( + ([key, value]) => + `${escapeHtmlForInvoice(key)}: ${escapeHtmlForInvoice(value)}` + ) + .join('')} ` ) diff --git a/src/store/invoiceStore.ts b/src/store/invoiceStore.ts index b836d1fa..fb14ade8 100644 --- a/src/store/invoiceStore.ts +++ b/src/store/invoiceStore.ts @@ -24,7 +24,11 @@ import { buildJurisdictionKey, isTaxExempt as checkIsTaxExempt, } from '../types/invoice'; -import { buildInvoice, calculateInvoiceTotals } from '../utils/invoice'; +import { + buildInvoice, + calculateInvoiceTotals, + validateInvoiceLineItemMetadata, +} from '../utils/invoice'; import { errorHandler, AppError } from '../services/errorHandler'; import { presentLocalNotification } from '../services/notificationService'; import { getNextSequence, generateLegalInvoiceNumber } from '../services/sequenceService'; @@ -63,7 +67,15 @@ const normalizeInvoice = (raw: Partial): Invoice => { subscriptionId: raw.subscriptionId ?? 'unknown', subscriptionName: raw.subscriptionName ?? 'Subscription', merchantName: raw.merchantName ?? 'Merchant', - lineItems: Array.isArray(raw.lineItems) ? raw.lineItems : [], + lineItems: Array.isArray(raw.lineItems) + ? raw.lineItems.map((item) => ({ + ...item, + metadata: + validateInvoiceLineItemMetadata(item?.metadata).length === 0 + ? item?.metadata + : undefined, + })) + : [], tax: Number.isFinite(raw.tax) ? (raw.tax as number) : 0, total: Number.isFinite(raw.total) ? (raw.total as number) : 0, subtotal: Number.isFinite(raw.subtotal) ? (raw.subtotal as number) : 0, diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 21f69742..58309fe7 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -247,10 +247,14 @@ export interface TaxInvoiceGenerationInput { reverseCharge?: boolean; } +export type InvoiceLineItemMetadata = Record; + export interface InvoiceLineItem { /** Optional line identifier assigned by the caller. */ id?: string; description: string; + /** Arbitrary string key/value references attached to this line item. */ + metadata?: InvoiceLineItemMetadata; quantity: number; unitPrice: number; currency: string; diff --git a/src/utils/__tests__/invoicePrint.test.ts b/src/utils/__tests__/invoicePrint.test.ts index 566c7e20..5e1a2033 100644 --- a/src/utils/__tests__/invoicePrint.test.ts +++ b/src/utils/__tests__/invoicePrint.test.ts @@ -1,4 +1,9 @@ -import { generatePrintableInvoiceHtml, buildInvoice, buildBillingPeriod } from '../invoice'; +import { + generatePrintableInvoiceHtml, + buildInvoice, + buildBillingPeriod, + validateInvoiceLineItemMetadata, +} from '../invoice'; import { Subscription, BillingCycle, @@ -53,4 +58,26 @@ describe('Printable Invoice Utility (Issue #1277)', () => { expect(html).toContain('Notes:'); expect(html).toContain('Special corporate rate applied'); }); + + it('renders line-item metadata in the printable invoice', () => { + const period = buildBillingPeriod(mockSubscription); + const invoice = buildInvoice(mockSubscription, 3, period); + invoice.lineItems[0].metadata = { reference: 'PO-123', source: 'migration' }; + + const html = generatePrintableInvoiceHtml(invoice); + + expect(html).toContain('reference:'); + expect(html).toContain('PO-123'); + expect(html).toContain('source:'); + }); + + it('validates metadata as bounded string key/value pairs', () => { + expect(validateInvoiceLineItemMetadata({ reference: 'PO-123' })).toEqual([]); + expect(validateInvoiceLineItemMetadata({ reference: 123 })).toContain( + 'line item metadata.reference must be a string' + ); + expect(validateInvoiceLineItemMetadata(null)).toEqual([ + 'line item metadata must be an object of string key/value pairs', + ]); + }); }); diff --git a/src/utils/invoice.ts b/src/utils/invoice.ts index 98cfe2a2..061911c2 100644 --- a/src/utils/invoice.ts +++ b/src/utils/invoice.ts @@ -12,6 +12,41 @@ import { MeterUsageBreakdown } from '../types/usage'; import { formatCurrency, formatDate } from './formatting'; import { ProrationPreview, buildProrationLineItem } from './proration'; +/** Limits keep invoice metadata useful while preventing oversized persisted/PDF payloads. */ +export const MAX_LINE_ITEM_METADATA_ENTRIES = 50; +export const MAX_LINE_ITEM_METADATA_KEY_LENGTH = 100; +export const MAX_LINE_ITEM_METADATA_VALUE_LENGTH = 500; + +export const validateInvoiceLineItemMetadata = ( + metadata: unknown, + fieldName = 'line item metadata' +): string[] => { + if (metadata === undefined) return []; + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + return [`${fieldName} must be an object of string key/value pairs`]; + } + const entries = Object.entries(metadata as Record); + const errors: string[] = []; + if (entries.length > MAX_LINE_ITEM_METADATA_ENTRIES) { + errors.push(`${fieldName} cannot contain more than ${MAX_LINE_ITEM_METADATA_ENTRIES} entries`); + } + for (const [key, value] of entries) { + if (!key.trim()) errors.push(`${fieldName} keys must not be empty`); + if (key.length > MAX_LINE_ITEM_METADATA_KEY_LENGTH) { + errors.push( + `${fieldName} keys must be ${MAX_LINE_ITEM_METADATA_KEY_LENGTH} characters or fewer` + ); + } + if (typeof value !== 'string') errors.push(`${fieldName}.${key} must be a string`); + else if (value.length > MAX_LINE_ITEM_METADATA_VALUE_LENGTH) { + errors.push( + `${fieldName}.${key} must be ${MAX_LINE_ITEM_METADATA_VALUE_LENGTH} characters or fewer` + ); + } + } + return errors; +}; + const SECOND = 1000; const DAY = 24 * 60 * 60 * SECOND; /** Basis points per whole unit (100 % = 10 000 bps). */ @@ -20,6 +55,14 @@ const BPS_PER_UNIT = 10_000; /** Round a money value to the invoice currency's minor unit (2 decimals). */ const roundMoney = (value: number): number => Math.round(value * 100) / 100; +const escapeHtml = (value: unknown): string => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + /** * Tax owed on `subtotal` at `taxRateBps`. * @@ -299,10 +342,10 @@ export const generateInvoicePdfPreview = (invoice: Invoice): string => { `Tax: ${formatCurrency(invoice.tax, invoice.currency)}`, `Total: ${formatCurrency(invoice.total, invoice.currency)}`, 'Items:', - ...invoice.lineItems.map( - (item) => - `${item.description} x${item.quantity} @ ${formatCurrency(item.unitPrice, item.currency)}` - ), + ...invoice.lineItems.flatMap((item) => [ + `${item.description} x${item.quantity} @ ${formatCurrency(item.unitPrice, item.currency)}`, + ...Object.entries(item.metadata ?? {}).map(([key, value]) => ` ${key}: ${value}`), + ]), ]; return lines.join('\n'); @@ -369,8 +412,14 @@ export const generatePrintableInvoiceHtml = (invoice: Invoice): string => { ${item.description} ${item.quantity} ${formatCurrency(item.unitPrice, item.currency)} - ${formatCurrency(item.lineTotal, item.currency)} - ` + ${formatCurrency(item.lineTotal ?? item.amount ?? 0, item.currency)} + + ${Object.entries(item.metadata ?? {}) + .map( + ([key, value]) => + `${escapeHtml(key)}: ${escapeHtml(value)}` + ) + .join('')}` ) .join('');