import type { Field, Choice } from '@/types/field' import { DEFAULT_CURRENCY_CODE, type CurrencyCode, getCurrencySymbolForCode, isCurrencyCode, } from '@/constants/supportedCurrencies' import type { ProductFieldConfig } from '@/utils/productFieldConfig' import { parseProductFieldConfig } from '@/utils/productFieldConfig' import { parseQuantityFieldConfig } from '@/utils/quantityFieldConfig' /** * Parse a money-like string to float: keep digits/commas/dots, treat the last `.` or `,` as the * decimal separator, strip other separators as thousands, then parse (0 if non-finite). */ function parseMoneyToFloat(raw: string): number { const t = String(raw ?? '') .trim() .replace(/\s/g, '') if (t === '') { return 0 } let cleaned = t.replace(/[^\d.,]/g, '') cleaned = cleaned.replace(/[.,]+$/g, '') if (cleaned === '' || cleaned === '.' || cleaned === ',') { return 0 } const lastComma = cleaned.lastIndexOf(',') const lastDot = cleaned.lastIndexOf('.') const decIndex = Math.max(lastComma, lastDot) if (decIndex === -1) { const digits = cleaned.replace(/[^\d]/g, '') const n = Number.parseFloat(digits || '0') return Number.isFinite(n) ? n : 0 } const intWithThousands = cleaned.slice(0, decIndex) const frac = cleaned.slice(decIndex + 1).replace(/[^\d]/g, '') const intDigits = intWithThousands.replace(/[^\d]/g, '') if (intDigits === '' && frac === '') { return 0 } const normalized = frac !== '' ? `${intDigits || '0'}.${frac}` : intDigits || '0' const n = Number.parseFloat(normalized) return Number.isFinite(n) ? n : 0 } function findChoiceForPostedValue(optionValue: string, opts: Choice[]): Choice | undefined { const needle = String(optionValue).trim() if (needle === '') { return undefined } return opts.find( (o) => String(o.value) === needle || String(o.id) === needle || (Number.isFinite(Number(needle)) && String(o.value) === String(Number(needle))), ) } function priceFromMap(cfg: ProductFieldConfig, keys: string[]): string { for (const k of keys) { const raw = cfg.prices[k] if (typeof raw === 'string' && raw.trim() !== '') { return raw.trim() } } return '' } function optionUnitPrice(cfg: ProductFieldConfig, optionValue: string, opts: Choice[]): number { const needle = String(optionValue).trim() const direct = priceFromMap(cfg, [needle, String(Number(needle))]) if (direct !== '') { return parseMoneyToFloat(direct) } const opt = findChoiceForPostedValue(optionValue, opts) if (!opt) { return 0 } const fromChoice = priceFromMap(cfg, [String(opt.value), String(opt.id)]) return fromChoice !== '' ? parseMoneyToFloat(fromChoice) : 0 } /** * Computes the configured monetary subtotal for a product field given the current posted value. * This mirrors the pricing rules used by the public product field UI. */ export function computeProductSelectionSubtotal(productField: Field, rawValue: unknown): number { const cfg = parseProductFieldConfig(productField) const opts: Choice[] = Array.isArray(productField.fieldOptions) ? productField.fieldOptions : [] if (cfg.productType === 'single') { return parseMoneyToFloat(cfg.paymentAmount ?? '') } if (cfg.productType === 'user_defined') { return 0 } if (cfg.productType === 'checkbox') { const values: string[] = Array.isArray(rawValue) ? rawValue.map((v) => String(v)) : String(rawValue ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean) return values.reduce((sum, val) => sum + optionUnitPrice(cfg, val, opts), 0) } const scalar = Array.isArray(rawValue) && rawValue.length > 0 ? String(rawValue[0]) : String(rawValue ?? '') if (scalar.trim() === '') { return 0 } return optionUnitPrice(cfg, scalar, opts) } export function parseQuantityFormValue(raw: unknown): number { if (raw === null || raw === undefined || raw === '') { return 0 } const n = typeof raw === 'number' ? raw : Number(String(raw).trim()) if (!Number.isFinite(n)) { return 0 } return n } export function formatTotalAmountForDisplay(amount: number): string { if (!Number.isFinite(amount)) { return '0' } const rounded = Math.round(amount * 100) / 100 const s = rounded.toFixed(2) if (s.endsWith('.00')) { return String(Math.round(rounded)) } return s } /** * Entry list / detail display: stored total is numeric text; append form currency symbol. */ export function formatTotalFieldEntryDisplay(raw: unknown, currencyCode?: string): string { const s = raw === null || raw === undefined ? '' : String(raw).trim() if (s === '') { return '' } const code: CurrencyCode = typeof currencyCode === 'string' && isCurrencyCode(currencyCode) ? currencyCode : DEFAULT_CURRENCY_CODE return `${s} ${getCurrencySymbolForCode(code)}` } /** One row in the payment breakdown (product line × quantity). */ export type PaymentCartLine = { productFieldIndex: number quantityFieldIndex: number | null label: string unitSubtotal: number quantity: number lineTotal: number } function fieldModelKey(field: Field): string { return `${field.type}_${field.fieldIndex}` } /** * Pairs each product field with a quantity field (by explicit quantity→product link, else positional), * then returns unit subtotal × quantity per line. Grand total is the sum of `lineTotal`. */ export function buildPaymentCartLines( fields: Field[], values: Record, ): PaymentCartLine[] { const products = fields .filter((f) => f.type === 'product') .sort((a, b) => a.fieldIndex - b.fieldIndex) const quantitiesAll = fields .filter((f) => f.type === 'quantity') .sort((a, b) => a.fieldIndex - b.fieldIndex) const usedQuantityFieldIndexes = new Set() return products.map((productField, positionIndex) => { const pKey = fieldModelKey(productField) const rawProduct = values[pKey] const linkedCandidates = quantitiesAll .filter( (q) => !usedQuantityFieldIndexes.has(q.fieldIndex) && parseQuantityFieldConfig(q).linkedProductFieldIndex === productField.fieldIndex, ) .sort((a, b) => a.fieldIndex - b.fieldIndex) let quantityField: Field | undefined = linkedCandidates[0] if (!quantityField) { const unusedUnlinked = quantitiesAll.filter( (q) => !usedQuantityFieldIndexes.has(q.fieldIndex) && parseQuantityFieldConfig(q).linkedProductFieldIndex === null, ) quantityField = unusedUnlinked[positionIndex] } if (quantityField) { usedQuantityFieldIndexes.add(quantityField.fieldIndex) } const unitSubtotal = computeProductSelectionSubtotal(productField, rawProduct) const quantity = quantityField ? parseQuantityFormValue(values[fieldModelKey(quantityField)]) : 1 const safeQty = quantity > 0 ? quantity : 0 const lineTotal = unitSubtotal * safeQty const label = String(productField.label || '').trim() !== '' ? String(productField.label || '').trim() : `Product #${productField.fieldIndex}` return { productFieldIndex: productField.fieldIndex, quantityFieldIndex: quantityField ? quantityField.fieldIndex : null, label, unitSubtotal, quantity: safeQty, lineTotal, } }) } export function sumPaymentCartLineTotals(lines: PaymentCartLine[]): number { return lines.reduce( (sum, line) => sum + (Number.isFinite(line.lineTotal) ? line.lineTotal : 0), 0, ) }