/// import { type Cell, Default, handler, lift, recipe, str } from "commontools"; interface InvoiceItemInput { id?: string; description?: string; quantity?: number; unitPrice?: number; itemDiscountRate?: number; } interface InvoiceItem { id: string; description: string; quantity: number; unitPrice: number; itemDiscountRate: number; } interface InvoiceLineSummary extends InvoiceItem { baseTotal: number; itemDiscountAmount: number; lineTotal: number; } interface InvoiceTotals { subtotal: number; itemDiscountTotal: number; invoiceDiscountRate: number; invoiceDiscountAmount: number; discountedSubtotal: number; taxRate: number; taxAmount: number; totalDue: number; } interface InvoiceGeneratorArgs { items: Default; taxRate: Default; invoiceDiscountRate: Default; } interface UpdateItemEvent extends InvoiceItemInput { id?: string; } interface RateUpdateEvent { taxRate?: number; invoiceDiscountRate?: number; } const defaultItems: InvoiceItemInput[] = [ { id: "design-services", description: "Design sprint and prototyping", quantity: 12, unitPrice: 120, itemDiscountRate: 0.1, }, { id: "implementation", description: "Implementation sprint", quantity: 40, unitPrice: 95, itemDiscountRate: 0.05, }, { id: "managed-hosting", description: "Managed hosting", quantity: 12, unitPrice: 12.5, itemDiscountRate: 0, }, ]; const roundCurrency = (value: number): number => { return Math.round(value * 100) / 100; }; const roundRate = (value: number): number => { return Math.round(value * 10000) / 10000; }; const clampRate = (value: unknown, fallback: number): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; } if (value < 0) return 0; if (value > 1) return 1; return roundRate(value); }; const sanitizeQuantity = (value: unknown, fallback: number): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return Math.max(0, Math.round(fallback)); } const normalized = Math.round(value); return normalized >= 0 ? normalized : 0; }; const sanitizeMoney = (value: unknown, fallback: number): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return roundCurrency(Math.max(0, fallback)); } return roundCurrency(Math.max(0, value)); }; const sanitizeText = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) { return trimmed.slice(0, 80); } } return fallback; }; const slugify = (value: string): string => { return value.toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); }; const sanitizeIdentifier = (value: unknown, fallback: string): string => { if (typeof value === "string") { const slug = slugify(value.trim()); if (slug.length > 0) return slug; } const fallbackSlug = slugify(fallback); return fallbackSlug.length > 0 ? fallbackSlug : "line"; }; const ensureUniqueId = ( candidate: string, used: Set, fallback: string, ): string => { const base = candidate.length > 0 ? candidate : fallback; let id = base.length > 0 ? base : fallback; let suffix = 2; while (used.has(id)) { id = `${base}-${suffix}`; suffix++; } used.add(id); return id; }; const fallbackItemForIndex = (index: number): InvoiceItemInput => { const template = defaultItems[index]; if (template) return template; return { id: `line-${index + 1}`, description: `Line ${index + 1}`, quantity: 1, unitPrice: 100, itemDiscountRate: 0, }; }; const sanitizeInvoiceItem = ( raw: InvoiceItemInput | undefined, fallback: InvoiceItemInput, index: number, used: Set, ): InvoiceItem => { const fallbackId = sanitizeIdentifier(fallback.id, `line-${index + 1}`); const candidateId = sanitizeIdentifier(raw?.id, fallbackId); const id = ensureUniqueId(candidateId, used, fallbackId); const fallbackDescription = sanitizeText( fallback.description, `Line ${index + 1}`, ); const description = sanitizeText(raw?.description, fallbackDescription); const fallbackQuantity = sanitizeQuantity(fallback.quantity, 1); const quantity = sanitizeQuantity(raw?.quantity, fallbackQuantity); const fallbackUnitPrice = sanitizeMoney(fallback.unitPrice, 100); const unitPrice = sanitizeMoney(raw?.unitPrice, fallbackUnitPrice); const fallbackDiscount = clampRate(fallback.itemDiscountRate, 0); const itemDiscountRate = clampRate( raw?.itemDiscountRate, fallbackDiscount, ); return { id, description, quantity, unitPrice, itemDiscountRate }; }; const sanitizeItemList = ( value: readonly InvoiceItemInput[] | undefined, previous?: readonly InvoiceItem[], ): InvoiceItem[] => { const base = Array.isArray(value) ? value : []; const source = base.length > 0 ? base : defaultItems; const sanitized: InvoiceItem[] = []; const used = new Set(); for (let index = 0; index < source.length; index++) { const raw = source[index]; const fallback = previous?.[index] ?? fallbackItemForIndex(index); const item = sanitizeInvoiceItem(raw, fallback, index, used); sanitized.push(item); } if (sanitized.length === 0) { return sanitizeItemList(defaultItems); } return sanitized; }; const computeLineSummaries = ( items: readonly InvoiceItem[], ): InvoiceLineSummary[] => { return items.map((item) => { const baseTotal = roundCurrency(item.quantity * item.unitPrice); const itemDiscountAmount = roundCurrency( baseTotal * clampRate(item.itemDiscountRate, 0), ); const lineTotal = roundCurrency(baseTotal - itemDiscountAmount); return { ...item, baseTotal, itemDiscountAmount, lineTotal, }; }); }; const computeInvoiceTotals = ( lines: readonly InvoiceLineSummary[], invoiceDiscountRate: number, taxRate: number, ): InvoiceTotals => { const subtotal = roundCurrency( lines.reduce((total, line) => total + line.lineTotal, 0), ); const itemDiscountTotal = roundCurrency( lines.reduce((total, line) => total + line.itemDiscountAmount, 0), ); const normalizedInvoiceDiscount = clampRate(invoiceDiscountRate, 0); const invoiceDiscountAmount = roundCurrency( subtotal * normalizedInvoiceDiscount, ); const discountedSubtotal = roundCurrency( subtotal - invoiceDiscountAmount, ); const normalizedTaxRate = clampRate(taxRate, 0); const taxAmount = roundCurrency(discountedSubtotal * normalizedTaxRate); const totalDue = roundCurrency(discountedSubtotal + taxAmount); return { subtotal, itemDiscountTotal, invoiceDiscountRate: normalizedInvoiceDiscount, invoiceDiscountAmount, discountedSubtotal, taxRate: normalizedTaxRate, taxAmount, totalDue, }; }; const formatCurrency = (value: number): string => { return `$${value.toFixed(2)}`; }; const updateItemDetails = handler( ( event: UpdateItemEvent | undefined, context: { items: Cell }, ) => { const current = sanitizeItemList(context.items.get()); const id = event?.id ? sanitizeIdentifier(event.id, "") : ""; if (!id) return; const updated = current.map((entry) => { if (entry.id !== id) return entry; return { ...entry, description: event?.description ?? entry.description, quantity: typeof event?.quantity === "number" ? sanitizeQuantity(event.quantity, entry.quantity) : entry.quantity, unitPrice: typeof event?.unitPrice === "number" ? sanitizeMoney(event.unitPrice, entry.unitPrice) : entry.unitPrice, itemDiscountRate: typeof event?.itemDiscountRate === "number" ? clampRate(event.itemDiscountRate, entry.itemDiscountRate) : entry.itemDiscountRate, }; }); const sanitized = sanitizeItemList(updated, current); context.items.set(sanitized); }, ); const addInvoiceItem = handler( ( event: InvoiceItemInput | undefined, context: { items: Cell }, ) => { const current = sanitizeItemList(context.items.get()); const nextList = [...current, event ?? {}]; const sanitized = sanitizeItemList(nextList, current); context.items.set(sanitized); }, ); const updateRates = handler( ( event: RateUpdateEvent | undefined, context: { taxRate: Cell; invoiceDiscountRate: Cell; }, ) => { if (typeof event?.taxRate === "number") { context.taxRate.set( clampRate(event.taxRate, context.taxRate.get()), ); } if (typeof event?.invoiceDiscountRate === "number") { context.invoiceDiscountRate.set( clampRate(event.invoiceDiscountRate, context.invoiceDiscountRate.get()), ); } }, ); export const invoiceGeneratorPattern = recipe( "Invoice Generator Pattern", ({ items, taxRate, invoiceDiscountRate }) => { const normalizedItems = lift((value: InvoiceItemInput[] | undefined) => sanitizeItemList(value) )(items); const normalizedTaxRate = lift((value: number | undefined) => clampRate(value, 0.0725) )(taxRate); const normalizedInvoiceDiscountRate = lift((value: number | undefined) => clampRate(value, 0.05) )(invoiceDiscountRate); const lineSummaries = lift((entries: InvoiceItem[] | undefined) => computeLineSummaries(Array.isArray(entries) ? entries : []) )(normalizedItems); const totals = lift((input: { lines: InvoiceLineSummary[]; invoiceDiscountRate: number; taxRate: number; }) => computeInvoiceTotals( input.lines, input.invoiceDiscountRate, input.taxRate, ) )({ lines: lineSummaries, invoiceDiscountRate: normalizedInvoiceDiscountRate, taxRate: normalizedTaxRate, }); const subtotal = lift((value: InvoiceTotals | undefined) => value?.subtotal ?? 0 )(totals); const itemDiscountTotal = lift((value: InvoiceTotals | undefined) => value?.itemDiscountTotal ?? 0 )(totals); const invoiceDiscountAmount = lift((value: InvoiceTotals | undefined) => value?.invoiceDiscountAmount ?? 0 )(totals); const discountedSubtotal = lift((value: InvoiceTotals | undefined) => value?.discountedSubtotal ?? 0 )(totals); const taxAmount = lift((value: InvoiceTotals | undefined) => value?.taxAmount ?? 0 )(totals); const totalDue = lift((value: InvoiceTotals | undefined) => value?.totalDue ?? 0 )(totals); const formattedTotalDue = lift((value: number | undefined) => formatCurrency(value ?? 0) )(totalDue); const taxRatePercent = lift((value: number | undefined) => `${((value ?? 0) * 100).toFixed(2)}%` )(normalizedTaxRate); const invoiceDiscountPercent = lift((value: number | undefined) => `${((value ?? 0) * 100).toFixed(2)}%` )(normalizedInvoiceDiscountRate); const lineCount = lift((entries: InvoiceLineSummary[] | undefined) => Array.isArray(entries) ? entries.length : 0 )(lineSummaries); const lineLabels = lift((entries: InvoiceLineSummary[] | undefined) => { if (!Array.isArray(entries)) return []; return entries.map((entry) => `${entry.description}: ${formatCurrency(entry.lineTotal)}` ); })(lineSummaries); const summary = str`Total due ${formattedTotalDue} (tax ${taxRatePercent}, discount ${invoiceDiscountPercent})`; return { items, normalizedItems, lineSummaries, totals, subtotal, itemDiscountTotal, invoiceDiscountAmount, discountedSubtotal, taxAmount, totalDue, taxRatePercent, invoiceDiscountPercent, lineCount, lineLabels, formattedTotalDue, summary, controls: { addItem: addInvoiceItem({ items }), updateItem: updateItemDetails({ items }), updateRates: updateRates({ taxRate, invoiceDiscountRate, }), }, }; }, );