mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
435b782c85
Requested in discussion #1658: an expense carries an amount and a category, and everything else about it ends up crammed into the title. The column was already there. It just was not available, because the costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an expense split by receipt could never carry a note - and saving one any other way wrote note=null, which quietly wiped whatever had been typed into the budget table. The receipt gets its own column and note goes back to being text. Old clients that still send the blob as a note are routed to the right column instead of clobbering the note. On desktop the row now has fixed columns, so the amount does not drift left when one row has a note and the next does not. The category moved to the card edge as a tab like the mobile card does, which is what frees the line the note sits on. Collapsed it is one line, and the same element grows in place when opened. The dialog is two columns now: what the expense was on the left, how it is shared on the right, grouped into panels the way the trip dialog does it. On the phone the eleven category pills became a dropdown, which is where the room for the note field came from, and a note on a card unfolds when you tap it.
172 lines
6.3 KiB
TypeScript
172 lines
6.3 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).
|
|
*/
|
|
|
|
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
|
|
export function splitCents(amount: number, n: number): number[] {
|
|
if (n <= 0) return []
|
|
const cents = Math.max(0, 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)
|
|
}
|
|
|
|
/** Sum the amounts of the selected payers. */
|
|
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
|
return [...ids].reduce((a, id) => a + (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 + (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.
|
|
*/
|
|
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 % 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. */
|
|
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
|
|
const shares: Record<number, number> = {}
|
|
let totalCents = 0
|
|
|
|
for (const item of items) {
|
|
const priceNum = parseFloat(item.price) || 0
|
|
const priceCents = Math.round(priceNum * 100)
|
|
totalCents += priceCents
|
|
|
|
const partIds = [...item.participants]
|
|
const n = partIds.length
|
|
if (n === 0) continue
|
|
|
|
const baseCents = Math.floor(priceCents / n)
|
|
const remainder = priceCents % n
|
|
|
|
const sortedPartIds = [...partIds].sort((a, b) => a - b)
|
|
|
|
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 }
|
|
}
|