Commit Graph

8 Commits

Author SHA1 Message Date
Maurice 06bee95ae3 fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate
of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44
EUR. The Costs screen and the settlement then turned those euros back
into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter
rounds the two quotes separately, so they are not exact inverses, and
the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01.
The error grows with the amount, which is why the large rows showed it
first. Paying back what settle-up offered for such a bill left the trip
open with the debt flipped the other way.

Both sides now convert trip money to the display currency with the trip
currency's own quote, the one every entry rate is frozen from. On the
server, settlement() fetches that quote for the REST route and for the
MCP get_settlement_summary tool, which now makes the same call, and a
legacy transfer without a currency reads back through the same factor.
On the client, useExchangeRates takes the trip currency as an anchor
and converts through its cached quote, falling back to the display
currency's while it is missing, so it works offline from cache and
needs no change to the settlement response. The final budget builds
each row from the unrounded trip amount, because a euro cent is worth
more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the
balances and settle-up still net whole trip cents.

A same-day bill in the display currency now reads exactly as typed on
the row, the payer chip, the day header, the totals, Outstanding and
the final budget, on both shells and on the shared trip page, and
paying the amount settle-up offers leaves both balances at zero. When
the rate really moved, the row keeps the value the totals count, with
what was entered and the euros it was booked at underneath, "$801.76 ->
685,26 EUR". One helper builds that line for the desktop list, the
phone list, payments, the final budget breakdown and the shared page;
it prints nothing for an amount of 0 or a bill that reads as typed.

The expense editors share one module for their preview. The split
summary puts the share the row lends or borrows next to the entered
one, "$400.88 -> $390.53 each", and an expense saved without a currency
still opens in the trip currency. Both CSV exports add the amount in
the trip currency when it differs from the display currency.

Kept from before: the desktop totals recompute when rates arrive, a
booking's linked cost is labelled in the trip currency, a legacy
transfer is read in the display currency, the shared page reads the
frozen rate in whole cents, a booking import keeps the currency it was
quoted in at a rate frozen before the write (REST and plugin RPC), and
the MCP trip summary, the budget-overview prompt and the per-person
summary add every row up in trip cents at its frozen rate. The
packing-list prompt reads the trip title directly instead of building
the whole summary, which could wait on a rates fetch.
2026-09-26 04:22:50 +00:00
Maurice 6a3d4b1281 fix(budget): stop receipts deleting files, and share the upload path
Attaching a receipt no longer lets the budget domain delete one. Trashing the
file was reachable from the REST route, the MCP tool and the plugin RPC, none
of which check file_delete, and a receipt id is any file on the trip, so anyone
who could edit an expense could trash a document they were not allowed to
touch. A concurrent edit could do it by accident: the client sends the full set
of receipt ids from a snapshot taken when the form opened, so saving an expense
someone else had just attached a file to moved that file to the trash. Removing
a receipt now removes the link and nothing else.

The link row is treated as shared, because it is. One row can tie a file to a
place and to an expense at once, so the sync clears only the receipt half and
leaves the rest, the foreign key is SET NULL rather than CASCADE, and a receipt
already in the trash keeps its link so restoring it comes back attached.

The upload and its rollback moved into one module both shells call. It was
copied into six places, and all six rolled back with the permanent-delete
route, which only accepts a file that is already in the trash and therefore
answered 404 every time: a failed save left every uploaded file on the trip,
attached to the expense, and the next attempt uploaded another copy. It now
trashes first, keeps the pending files for the retry, and says so when a file
could not be removed instead of swallowing it.

Escape in the receipt viewer no longer closes the expense form behind it, the
download fallback uses a real accent class rather than a utility that does not
exist, and the upload indicator is actually switched on.

Tests: the receipt link behaviour on both service paths, the four new branches
in the files service, the upload module, and the viewer.
2026-09-08 23:15:26 +02:00
Maurice d1aa573167 fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) 2026-09-01 17:18:10 +02:00
Maurice 27be764164 test(client): the coverage that goes with the previous commits
Left out of the commits they belong to by hand, not by intent: the mobile
screen suites, the tripRepo cache-first read behind the startup-destination
fix, and the MSW handlers the stricter unhandled-request setting needs.
2026-08-23 11:59:12 +02:00
Maurice 3c5e406229 feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do

Recording what a museum ticket or a guided tour cost meant leaving the place,
opening the Costs tab and typing it in again — while a booking has had a Costs
block in its own form all along.

The place form now carries that block. Same component, same flow: with nothing
linked it offers Create expense, which saves the place and then opens the Costs
editor for a new expense pointing at it; once linked it shows the expense with
edit and remove. budget_items gains place_id beside the reservation_id it
already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop
and phone both, and the mobile editor carries the link through its own save path
rather than the desktop one.

The expense belongs to the place, not to a day assignment, so a place sitting on
five days is not five tickets. Hanging it off the assignment would have meant
adding a place to three days quietly creating three expenses and removing a day
quietly deleting one.

Deleting a place takes its expense with it in the same transaction and announces
it, on every path that deletes a place: REST, both MCP tools and the plugin RPC.

Closes #1298

* test(places): teach the RPC stubs about linkedExpenseIds

The delete paths read the linked expense before the place is gone, so the two
hand-built PlacesService stubs threw on the new method instead of reaching the
hook, the remove and the broadcast — four failures out of one missing line.
2026-08-12 21:37:12 +02:00
Maurice 435b782c85 Add a note field to expenses (#1888)
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.
2026-08-11 18:13:07 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice c884ddf527 test(mobile): cover the trip screens 2026-07-29 11:55:31 +02:00