mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
aa1f76ad11
A foreign expense without a frozen rate is converted with today's rate. When the server could not fetch rates itself, it counted the amount 1:1 in the trip currency, although the browser had the rates all along. A VND bill on an AUD trip was settled as millions of AUD. Such a row is now left out of the balances, flows, final budgets, trip totals and per-person summaries. The settlement reports it under `unconverted` and says which currency its figures are in; the trip summary lists the ids and the budget prompt says how many are missing. The browser lends the rates it already holds (`fallback_fx`) when it saves an expense or a settle-up transfer, and to heal existing rows through the new POST .../budget/freeze-rates route, which an editor's Costs view calls for the rows the settlement left out. The server still prefers its own rate, uses a lent one only while it is quoted against the trip currency, and never replaces a frozen or explicit rate. The settlement also takes the browser's display rate (`base_rate`) in place of a display quote the server cannot fetch; the browser leaves out a figure the query would refuse. The browser's own fetch gives up after 10 s. Changing a row's currency while no rate is available no longer keeps the old currency's rate; the row goes back to unfrozen. MCP gets the same through the new freeze_budget_rates tool, which only ever pins the server's own rate.
74 lines
3.3 KiB
TypeScript
74 lines
3.3 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import type { BudgetFallbackFx, BudgetUnconverted } from '@trek/shared'
|
|
import { fetchFreshRates, isSendableRate } from '../../hooks/useExchangeRates'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { isEffectivelyOffline } from '../../sync/networkMode'
|
|
|
|
/**
|
|
* Every set of rows already tried, by trip, trip currency and ids. The settlement lists a
|
|
* row it cannot count until a rate is frozen on it; one the browser has no rate for either
|
|
* stays listed, and without this every reload of the settlement would ask again.
|
|
*/
|
|
const tried = new Set<string>()
|
|
|
|
/** Test-only: the attempts outlive a vitest file's individual tests. */
|
|
export function resetFreezeAttempts(): void {
|
|
tried.clear()
|
|
}
|
|
|
|
/**
|
|
* Heals the rows the settlement leaves out (`unconverted`): expenses and transfers in a
|
|
* foreign currency that never froze a rate, while the server cannot fetch one. That is the
|
|
* setup where the server's own fetch fails and the browser's works, so an editor's browser
|
|
* lends its fresh rates to POST …/budget/freeze-rates. The server still prefers its own rate
|
|
* and never touches a row that is frozen already.
|
|
*
|
|
* Runs for an editor only, online, and once per set of rows. No request goes out when the
|
|
* browser has no rate for any of the listed currencies either. `onHealed` runs when the
|
|
* server froze something, so the caller can reload the settlement.
|
|
*
|
|
* Deliberately not in the mutation queue: the heal is derived and idempotent, and a replay
|
|
* hours later would freeze a rate from another day.
|
|
*/
|
|
export function useFreezeMissingRates({ tripId, tripCurrency, canEdit, unconverted, onHealed }: {
|
|
tripId: number
|
|
tripCurrency: string
|
|
canEdit: boolean
|
|
unconverted: BudgetUnconverted | null | undefined
|
|
onHealed: () => void
|
|
}): void {
|
|
const onHealedRef = useRef(onHealed)
|
|
useEffect(() => { onHealedRef.current = onHealed })
|
|
const mounted = useRef(true)
|
|
useEffect(() => {
|
|
mounted.current = true
|
|
return () => { mounted.current = false }
|
|
}, [])
|
|
|
|
const trip = (tripCurrency || '').toUpperCase()
|
|
const itemIds = [...(unconverted?.item_ids ?? [])].sort((a, b) => a - b).join(',')
|
|
const settlementIds = [...(unconverted?.settlement_ids ?? [])].sort((a, b) => a - b).join(',')
|
|
const currencies = (unconverted?.currencies ?? []).join(',')
|
|
|
|
useEffect(() => {
|
|
if (!canEdit || !trip || (!itemIds && !settlementIds) || isEffectivelyOffline()) return
|
|
const key = `${tripId}:${trip}:${itemIds}:${settlementIds}`
|
|
if (tried.has(key)) return
|
|
tried.add(key)
|
|
|
|
const heal = async (): Promise<void> => {
|
|
const rates = await fetchFreshRates(trip)
|
|
const lent: Record<string, number> = {}
|
|
for (const cur of currencies.split(',')) {
|
|
const rate = rates?.[cur]
|
|
if (isSendableRate(rate)) lent[cur] = rate
|
|
}
|
|
if (Object.keys(lent).length === 0) return
|
|
const fallback: BudgetFallbackFx = { base: trip, rates: lent }
|
|
const result = await useTripStore.getState().freezeMissingRates(tripId, fallback)
|
|
if (mounted.current && (result.items.length > 0 || result.settlements.length > 0)) onHealedRef.current()
|
|
}
|
|
heal().catch((err: unknown) => console.warn('Could not freeze the missing exchange rates:', err))
|
|
}, [canEdit, trip, tripId, itemIds, settlementIds, currencies])
|
|
}
|