Files
TREK/client/src/components/Budget/CostsPanel.helpers.ts
T
RemEx bd46089450 feat(budget): make a settle-up payment's date editable
The settle-up form only exposed from/to/amount and silently dated the
payment by created_at, unlike a regular expense which has an editable
date. Add a settled_at column (independent of created_at, same split as
expense_date) through the schema, migration, service, REST and MCP
surfaces, and the desktop + mobile forms; the ledger groups a payment by
settled_at, falling back to created_at for rows recorded before the
column existed.
2026-09-12 17:33:03 +02:00

216 lines
8.7 KiB
TypeScript

/**
* Pure payer math for the Costs expense modal.
*
* An expense's payers must always sum to its total. The server re-derives
* budget_items.total_price from the payer sum (budgetService.createItem), so an
* unbalanced payer list would silently rewrite the expense total — and in custom
* split mode the member debits, balanced against the old total, would stop
* cancelling the payer credits. rebalancePayers keeps the payers the user hasn't
* touched absorbing the remainder as they type; payersBalanced gates the save.
*
* Amounts are the raw input strings, parsed on use (same as customAmounts).
*/
import { currencyDecimals } from '../../utils/formatters'
// The split and receipt fields guard their own precision on every keystroke, so the
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
// places, and the old fixed two rejected every keystroke after that, leaving the field
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
// value a field accepts today can become uneditable.
export const amountPattern = (currency: string, signed: boolean) =>
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
/**
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
* Floor-based, so a negative total (a refund, #2176) splits just as exactly:
* the remainder is always in [0, n) and the parts still sum to the input.
*/
export function splitCents(amount: number, n: number): number[] {
if (n <= 0) return []
const cents = Math.round(amount * 100)
const base = Math.floor(cents / n)
const rem = cents - base * n
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
}
/**
* The calendar day a settle-up payment counts on for grouping/filtering: its own
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
* `created_at` doubled as its date before `settled_at` existed, so this keeps
* every pre-existing row grouped exactly where it already was.
*/
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
return (s.settled_at || s.created_at || '').slice(0, 10)
}
/** Sum the amounts of the selected payers. */
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
}
/** True when the payer amounts add up to the expense total, to the cent. */
export function payersBalanced(amounts: Record<number, string>, ids: Set<number>, total: number): boolean {
return Math.round(payerSum(amounts, ids) * 100) === Math.round(total * 100)
}
/**
* Recompute the payers the user has not explicitly edited (everyone not in
* `pinned`) so the whole list sums to `total`. Pinned amounts are left as typed.
*/
export function rebalancePayers(
amounts: Record<number, string>,
pinned: Set<number>,
ids: Set<number>,
total: number,
): Record<number, string> {
const all = [...ids]
const free = all.filter(id => !pinned.has(id))
if (free.length === 0) return amounts
const pinnedSum = all
.filter(id => pinned.has(id))
.reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
const shares = splitCents(total - pinnedSum, free.length)
const next = { ...amounts }
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
return next
}
/**
* Split `total` equally across `members`, in whole cents.
*
* The remainder cent rotates with the item id rather than always landing on the
* first member, so across several expenses the rounding evens out instead of
* always favouring the same person.
*
* Must stay share-for-share identical to the server's
* BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted
* there, this copy only previews it. The remainder is `totalCents - baseCents*n`
* rather than `%` so a negative total (a refund, #2176) still yields a remainder
* in [0, n) and the shares sum back to the total exactly, like the server's do.
* The parity fixture in CostsPanel.helpers.test.ts pins both sides.
*/
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
const n = members.length
if (n === 0) return {}
const totalCents = Math.round(total * 100)
const baseCents = Math.floor(totalCents / n)
const remainder = totalCents - baseCents * n
const shares: Record<number, number> = {}
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
const startIndex = itemId % n
for (let i = 0; i < n; i++) {
const member = sortedMembers[i]
const hasExtraCent = ((i - startIndex + n) % n) < remainder
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
}
return shares
}
/** One line of a receipt: what it cost and who is in on it. */
export interface TicketItem {
id: string
name: string
price: string
participants: Set<number>
}
/**
* Read the itemized receipt off an expense (#1658).
*
* It lives in `ticket_json` since migration 186. Before that it was smuggled
* through `note` behind a `TICKETJSON:` prefix, which is why an expense split by
* receipt could never carry a written note. The old shape is still read here so
* a response cached before the migration keeps rendering its split.
*/
export function readTicketItems(item: { ticket_json?: string | null; note?: string | null } | null | undefined): TicketItem[] {
const raw = item?.ticket_json ?? (item?.note?.startsWith('TICKETJSON:') ? item.note.slice(11) : null)
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return (parsed.items || []).map((line: { name?: string; price?: unknown; parts?: number[] }, i: number) => ({
id: `${i}`,
name: String(line.name ?? ''),
price: String(line.price ?? ''),
participants: new Set(line.parts || []),
}))
} catch {
return []
}
}
/** True when this expense is split line by line rather than equally or by amount. */
export function hasTicketSplit(item: { ticket_json?: string | null; note?: string | null } | null | undefined): boolean {
return Boolean(item?.ticket_json || item?.note?.startsWith('TICKETJSON:'))
}
/** Serialize the receipt lines for storage in `ticket_json`. */
export function writeTicketItems(items: TicketItem[]): string {
return JSON.stringify({ items: items.map(i => ({ name: i.name, price: i.price, parts: [...i.participants] })) })
}
/** Long enough for a receipt discrepancy or a reimbursement reminder, short
* enough that the row stays a row. */
export const NOTE_MAX = 500
/**
* The note a user actually typed, which is never the receipt blob. Only matters
* for data written before migration 186 moved the receipt out of the field.
*/
export function readUserNote(item: { note?: string | null } | null | undefined): string {
const note = item?.note
return note && !note.startsWith('TICKETJSON:') ? note : ''
}
/**
* Per-person totals for a receipt split line by line, plus the receipt total.
*
* Every cent on the receipt lands on somebody: a line nobody was ticked for is
* carried by everyone who appears on the receipt, so the shares add back up to
* the total. They used to count toward the total only, which left the expense
* with member debits that could never cancel the payer's credit — a permanent
* difference the settle-up view showed as debt but had no flow to clear (#1382).
* A receipt with no participants at all splits nothing; it is not saveable
* anyway, since every line needs someone on it.
*/
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
const shares: Record<number, number> = {}
let totalCents = 0
const everyone = [...new Set(items.flatMap(i => [...i.participants]))].sort((a, b) => a - b)
for (const item of items) {
const priceNum = Number.parseFloat(item.price) || 0
const priceCents = Math.round(priceNum * 100)
totalCents += priceCents
const sortedPartIds = item.participants.size > 0
? [...item.participants].sort((a, b) => a - b)
: everyone
const n = sortedPartIds.length
if (n === 0) continue
const baseCents = Math.floor(priceCents / n)
const remainder = priceCents - baseCents * n
for (let i = 0; i < n; i++) {
const id = sortedPartIds[i]
const hasExtraCent = i < remainder
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
shares[id] = (shares[id] || 0) + shareCents
}
}
const finalShares: Record<number, number> = {}
for (const id of Object.keys(shares)) {
finalShares[Number(id)] = shares[Number(id)] / 100
}
return { shares: finalShares, total: totalCents / 100 }
}