mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
b1c05bca83
Whether the AI Parsing model reads images is an admin setting now, and it lands with the two features that send a photo, so it has something to control. The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On Automatic a local Ollama is asked what the model can do (`/api/show`), and a cloud provider means no; Yes and No are applied as set. The old `multimodal` flag is not read: the admin screens wrote it false on every save, so a stored false meant nothing. The per-user toggle keeps its on/off meaning and is read by the extraction now. The lookup is server-side only: capped read, validated body, never throws, cached per server and model. GET /api/llm/capabilities answers it for the signed-in caller, and both photo pickers below ask it. The booking import takes JPG, PNG and WEBP when the model reads images. The photo goes as an image to every provider (image_url, an Anthropic image block, Ollama's native chat with `images`), capped at 1600px, and skips KDE Itinerary. A scanned PDF, which used to reach the model as empty text, has its first two pages drawn by pdf-parse and sent the same way. The rest is the import as it was: background job, review, the file attached to the booking, the linked cost. On a real qwen3.5:4b a train ticket came back as bare clock times until the router gave the model every type's field meanings, and its price as an object inside a string, which read as 4900; both are handled. Costs gets Scan receipt: a dialog laid out like the booking import's takes one photo, read on the import job queue, then reviewed in the existing expense editor pre-filled with merchant, total, currency, day and lines, the photo waiting in Receipts & Invoices to be uploaded on save. No table, no migration, no category guess, no document-type detection.
325 lines
14 KiB
TypeScript
325 lines
14 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 type { BudgetParticipantFinal, ReceiptRead } from '@trek/shared'
|
|
import { amountToInputString, currencyDecimals } from '../../utils/formatters'
|
|
import type { ExpensePrefill } from './CostsPanel'
|
|
import type { BudgetItem } from '../../types'
|
|
|
|
// 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)
|
|
}
|
|
|
|
/**
|
|
* What one participant fronted for an expense, in the expense's own currency.
|
|
*
|
|
* Several payers can share one bill, and the same person can appear only once,
|
|
* but the reduce covers a row that somehow carries them twice rather than
|
|
* picking one of the two. The caller converts the result — this file never
|
|
* touches exchange rates.
|
|
*/
|
|
export function paidByUser(
|
|
item: { payers?: { user_id: number; amount: number }[] | null },
|
|
userId: number,
|
|
): number {
|
|
return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0)
|
|
}
|
|
|
|
/** The figures a final budget is made of, for someone the server left out of the ledger. */
|
|
export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal {
|
|
// Absent means they neither fronted anything nor were split into an expense:
|
|
// the trip has cost them nothing, which is worth a row of its own.
|
|
return finals.find(f => f.user_id === member.id) || {
|
|
user_id: member.id, username: member.username, avatar_url: null,
|
|
expenses: 0, reimbursed: 0, pending: 0, final: 0,
|
|
sources: { fronted: [], moved: [], outstanding: [] },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The rows behind one traveler's final budget, ready to print. They arrive as
|
|
* ids and display cents: the server spreads each of the three figures over its
|
|
* rows with the same largest-remainder split the figure came from, so every list
|
|
* adds up to the line it sits under, in whatever currency was asked for and
|
|
* whether or not the live rates have loaded yet. Only the expense names are
|
|
* looked up here; who "you" is in a transfer is the shell's call.
|
|
*/
|
|
export function finalBudgetSources(
|
|
row: Pick<BudgetParticipantFinal, 'sources'>,
|
|
items: { id: number; name: string }[],
|
|
): {
|
|
fronted: { item_id: number; name: string; amount: number }[]
|
|
moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[]
|
|
outstanding: { from_user_id: number; to_user_id: number; amount: number }[]
|
|
} {
|
|
const { fronted, moved, outstanding } = row.sources
|
|
return {
|
|
fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })),
|
|
moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
|
|
outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 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 + (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 }
|
|
}
|
|
|
|
/**
|
|
* Where a new expense's editor starts from a prefill: a booking's price, or a
|
|
* scanned receipt, which also brings its currency, day, lines and photo. Both
|
|
* editors (the desktop modal and the phone sheet) seed from this one place.
|
|
*
|
|
* A prefill without a currency is in `base`, which is also what the currency
|
|
* field starts on. Receipt lines seed the Ticket split, shared by everyone like
|
|
* a line added by hand, while the split itself stays Equally until the person
|
|
* switches.
|
|
*/
|
|
export function newExpenseSeed(prefill: ExpensePrefill | undefined, base: string, peopleIds: number[], today: string) {
|
|
const currency = (prefill?.currency || base).toUpperCase()
|
|
return {
|
|
currency,
|
|
day: prefill?.date || today,
|
|
total: prefill?.amount != null ? amountToInputString(prefill.amount, currency) : '',
|
|
ticketItems: (prefill?.lines ?? []).map((line, i): TicketItem => ({
|
|
id: `receipt-${i}`,
|
|
name: line.name,
|
|
price: amountToInputString(line.price, currency),
|
|
participants: new Set(peopleIds),
|
|
})),
|
|
receiptFiles: prefill?.receiptFiles ?? [],
|
|
}
|
|
}
|
|
|
|
/** A scanned receipt as the prefill of a new expense, with its photo to attach on save. */
|
|
export function receiptToPrefill(receipt: ReceiptRead, photos: File[]): ExpensePrefill {
|
|
return {
|
|
name: receipt.merchant ?? undefined,
|
|
amount: receipt.total ?? undefined,
|
|
currency: receipt.currency ?? undefined,
|
|
date: receipt.date ?? undefined,
|
|
lines: receipt.items,
|
|
receiptFiles: photos,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The expense editor a trip page shows, if any. Two things open it: a booking's
|
|
* Costs block (edit that expense, or start one from the booking's price) and a
|
|
* receipt scanned from Costs. The booking wins, since it is what the person just
|
|
* clicked; `key` remounts the editor when one hands over to the other, so each
|
|
* starts from its own seed.
|
|
*/
|
|
export function expenseEditorFor(
|
|
booking: { editing: BudgetItem | null; prefill?: ExpensePrefill } | null,
|
|
closeBooking: () => void,
|
|
receipt: ExpensePrefill | null,
|
|
closeReceipt: () => void,
|
|
): { key: string; editing: BudgetItem | null; prefill?: ExpensePrefill; close: () => void } | null {
|
|
if (booking) return { key: 'booking', editing: booking.editing, prefill: booking.prefill, close: closeBooking }
|
|
if (receipt) return { key: 'receipt', editing: null, prefill: receipt, close: closeReceipt }
|
|
return null
|
|
}
|