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('');