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
6 changes: 5 additions & 1 deletion backend/services/billing/invoiceCustomizationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) => `<div class="line-metadata"><strong>${escapeHtml(key)}:</strong> ${escapeHtml(value)}</div>`)
.join('');
return [
'<tr>',
`<td>${escapeHtml(item.description ?? 'Item')}</td>`,
`<td>${escapeHtml(item.description ?? 'Item')}${metadata}</td>`,
`<td class="num">${escapeHtml(quantity)}</td>`,
`<td class="num" style="color:${branding.textColor}">${escapeHtml(amount)}</td>`,
'</tr>',
Expand Down Expand Up @@ -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}',
'</style></head><body>',
Expand Down
11 changes: 11 additions & 0 deletions contracts/invoice/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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");
Expand All @@ -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<u64> = storage_instance_get(
Expand Down
8 changes: 6 additions & 2 deletions contracts/invoice/src/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<StdString>()
)
}

Expand Down
4 changes: 3 additions & 1 deletion contracts/types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<String, String>,
pub quantity: u32,
pub unit_price: i128,
pub currency: String,
Expand Down
25 changes: 24 additions & 1 deletion src/services/invoiceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ 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',
BRANDING: '@SubTrackr:invoiceBranding',
TEMPLATES: '@SubTrackr:invoiceTemplates',
} as const;

const escapeHtmlForInvoice = (value: unknown): string =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

// Branding Management
export async function saveBranding(
branding: Omit<InvoiceBranding, 'id' | 'createdAt' | 'updatedAt'>
Expand Down Expand Up @@ -149,6 +158,10 @@ export async function createInvoice(data: InvoiceFormData): Promise<Invoice> {
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),
Expand Down Expand Up @@ -220,6 +233,10 @@ export async function updateInvoice(id: string, updates: Partial<Invoice>): 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),
Expand Down Expand Up @@ -470,7 +487,13 @@ async function generateInvoiceHTML(
<td style="padding: 8px; border-bottom: 1px solid #E5E7EB;">${item.description}</td>
<td style="padding: 8px; border-bottom: 1px solid #E5E7EB; text-align: center;">${item.quantity}</td>
<td style="padding: 8px; border-bottom: 1px solid #E5E7EB; text-align: right;">${invoice.currency} ${item.unitPrice.toFixed(2)}</td>
<td style="padding: 8px; border-bottom: 1px solid #E5E7EB; text-align: right;">${invoice.currency} ${item.lineTotal.toFixed(2)}</td>
<td style="padding: 8px; border-bottom: 1px solid #E5E7EB; text-align: right;">${invoice.currency} ${(item.lineTotal ?? item.amount ?? 0).toFixed(2)}</td>
${Object.entries(item.metadata ?? {})
.map(
([key, value]) =>
`<tr><td colspan="4" style="padding: 0 8px 6px; color: #6B7280; font-size: 11px;"><strong>${escapeHtmlForInvoice(key)}:</strong> ${escapeHtmlForInvoice(value)}</td></tr>`
)
.join('')}
</tr>
`
)
Expand Down
16 changes: 14 additions & 2 deletions src/store/invoiceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -63,7 +67,15 @@ const normalizeInvoice = (raw: Partial<Invoice>): 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,
Expand Down
4 changes: 4 additions & 0 deletions src/types/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,14 @@ export interface TaxInvoiceGenerationInput {
reverseCharge?: boolean;
}

export type InvoiceLineItemMetadata = Record<string, string>;

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;
Expand Down
29 changes: 28 additions & 1 deletion src/utils/__tests__/invoicePrint.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { generatePrintableInvoiceHtml, buildInvoice, buildBillingPeriod } from '../invoice';
import {
generatePrintableInvoiceHtml,
buildInvoice,
buildBillingPeriod,
validateInvoiceLineItemMetadata,
} from '../invoice';
import {
Subscription,
BillingCycle,
Expand Down Expand Up @@ -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',
]);
});
});
61 changes: 55 additions & 6 deletions src/utils/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>);
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). */
Expand All @@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

/**
* Tax owed on `subtotal` at `taxRateBps`.
*
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -369,8 +412,14 @@ export const generatePrintableInvoiceHtml = (invoice: Invoice): string => {
<td style="padding: 10px; border-bottom: 1px solid #e5e7eb;">${item.description}</td>
<td style="padding: 10px; border-bottom: 1px solid #e5e7eb; text-align: center;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #e5e7eb; text-align: right;">${formatCurrency(item.unitPrice, item.currency)}</td>
<td style="padding: 10px; border-bottom: 1px solid #e5e7eb; text-align: right;">${formatCurrency(item.lineTotal, item.currency)}</td>
</tr>`
<td style="padding: 10px; border-bottom: 1px solid #e5e7eb; text-align: right;">${formatCurrency(item.lineTotal ?? item.amount ?? 0, item.currency)}</td>
</tr>
${Object.entries(item.metadata ?? {})
.map(
([key, value]) =>
`<tr><td colspan="4" style="padding: 0 10px 8px; color: #6b7280; font-size: 12px;"><strong>${escapeHtml(key)}:</strong> ${escapeHtml(value)}</td></tr>`
)
.join('')}`
)
.join('');

Expand Down
Loading