Repository navigation
feat(web): scan receipts and attach several files per expense - #33
Merged
Merged
Conversation
parseReceipt in core turns OCR text into suggestions for the expense form: amounts with either decimal mark, VAT class letters, quantity lines, tax and tip kept only when they make the bill add up, and dates in numeric or written form in five languages. Tests use till rolls from the US, UK, Germany, France, Spain, Brazil and Japan. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
expense_attachments holds up to five receipts, photos or PDFs per expense, still in the private receipts bucket under the group folder, so the member and two-step storage policies cover every file. set_expense_attachments replaces the list and logs one edit with the counts; receipt_path stays the first file, and a trigger keeps the list in step when an older client sets or clears it. Existing receipts become first attachments. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
"Scan a receipt" under the amount reads a photo with tesseract.js in a web worker, loaded only when someone scans. Its worker, core and English model are served from our own origin under /ocr/<version> and cached by the service worker the first time, so later scans work offline; none of it is in the bundle or the precache. The review sheet lists the name, date, items and total with a box each, and fills the form only when asked: items go into the by-item editor with everyone on every line, and nothing saves until the person saves. The receipt field becomes a strip of up to five photos and PDFs. Photos are shrunk to 2000 px JPEG before upload. The expense page shows them all and opens photos full screen (a fullscreen SheetContent variant), with arrows between them. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The journey scans a printed fixture receipt in the browser, fills the by-item editor, adds a second photo, saves, opens both full screen, and checks that group members can sign the files while an outsider cannot. Photos are always re-encoded once decoded, so their metadata never goes up. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
ADR 21, the attachments table, RPC and errors in architecture, a sequence for scanning and saving files, what scanning sends where in security, the new tests, agent notes per package, and the parity rows for Batch 10. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
… total
"2 Stk x 0,90" under an item is that item's count, not an item (also
"3 @ 1.50", "3 uds x", "4 un x", a count printed above its item, or one the
scan lost). Payment lines ("Geg. Karte", "Bar EUR", "Espèces", "Efectivo",
"Troco", "Visa", "Cash", "Change"...) never become items, and VAT summary
rows ("A= 7,0% 7,61 0,52 8,13") are neither items nor tax. A line dearer
than the whole bill is dropped as a misread, and itemsMatch says whether
the items come to the total (exact, close within 5%, off).
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
…an sweep set_expense_attachments right after the caller created the expense no longer logs a second entry: the files are part of creating it. orphan_receipts (service role only) lists receipt files older than a day that no expense or attachment names, and the receipts-sweep edge function, run daily by pg_cron with the shared secret, deletes them through the Storage API so the stored objects go too. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
…ice be fixed OCR read the shrunk upload JPEG, which smeared small print and lost items. It now reads a lossless grayscale copy of the original with its contrast stretched, and when the items do not come to the total it reads once more from an enlarged copy and keeps the better reading. On the judge's 18 rendered receipts, items matching the total went from 5 to 15. The review sheet shows the photo (pinch to zoom) beside the till roll, every price and the total can be corrected there, and a warning says when the items do not add up; items that are far off start unticked. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Photos pinch, double tap and drag to zoom, with buttons for the keyboard, and swipe to the next one when not zoomed. A PDF is "PDF 2" on the form and on the expense page, never cut short. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
A failed save no longer uploads the same files again when it is retried. Files the expense does not end up with are deleted after the save, and uploads for a save that never went through are deleted when the form closes. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
… only docs/receipts.md holds the receipts notes (the AGENTS files are at their size limit), ADR 21 and the sequence cover the second reading, editable prices, the single history entry and the sweep, and the parity matrix drops photos on comments: Splitwise comments are text only. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
…ogged The history rule no longer looks at a time window. create_expense_with_attachments saves the expense and its files in one transaction, so it has the one "added it" entry and a refused file means nothing is created. Every change through set_expense_attachments is logged, however soon after the create, so a receipt swap is always seen. pgTAP covers an edit right after creating, a create with files making exactly one entry, and a refused file. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
When two readings are still far off the total, the plain colour photo is
read too and the best of the three wins (the long grocery photo now gets
every item). Rule lines and smudges ("|", ">", "_") stay off item names, and
a day blurred from 07 to 97 is read as the 7th, which brings back the UK pub
photo's date. On the 18 receipts: dates 18, items adding up 16.
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
… one line The zoom buttons sit under the photo instead of on its print, and a disabled one stays visible. With the photo focused, + and - zoom, the arrow keys move a zoomed photo and 0 starts over; unzoomed, the arrows still go to the next photo. PDF tiles keep "PDF 2" on one line, and the sweep function answers POST only. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
…a test plan The attachments migration moves to 20261114015104 so it sorts after 20261113213127_deleted_groups_everywhere; it redefines nothing Batch 9 did. Policies and RPCs go through my_group_ids and require_member, so a deleted group's files and rows vanish with it and writes raise group_deleted. 01_schema keeps one plan (130) with the import and receipts checks side by side, and the docs note the purge and sweep interplay and the receipts feature notes next to the others. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
…the last reading A total read a few cents off (TOTAL $76.59 over VISA $76.50) gives way to a card or cash line that says exactly what the items say. A gap of under 1% adds no "Everything else" line. The colour reading stops after eight seconds and the readings so far stand. The viewer keeps its zoom frame mounted between photos, so focus stays on the button that moved to the next one, and long item names wrap anywhere. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The receipt parser's trailing-mark, quantity and edge patterns now run in linear time on long OCR lines, receipts-sweep logs its error and answers sweep_failed instead of the raw message, and the review sheet builds the photo's object URL with useMemo like the attachments field. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
previewUrl copies a picked file into a blob typed as the image it says it is, or as application/octet-stream otherwise, so a preview URL can never be read as a page or a script. The scan sheet and the attachments field both use it. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
musa-kal
force-pushed
the
feat/receipts
branch
from
November 14, 2026 04:33
77cd605 to
6a8c884
Compare
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Splitwise Pro parity: receipt scanning and several attachments per expense (ADR 21).
Must keep: money in integer minor units, additive migrations (20261114015104 sorts after main), anon cannot reach any of it, every string through i18n, and Batch 9 behaviour (deleted groups, subcategories, realtime) unchanged.
What Changed
/ocr/<version>/. When the first pass is far off, it runs a second pass and at most one more in colour. The newparseReceiptinpackages/coreturns the text into merchant, date, currency, total and items. It keeps tax, tip and discount only when they make the receipt add up, and payment lines can only confirm a total, never supply one. A review sheet shows the photo (zoom, pan, keyboard) with every field editable, and "Fill in the expense" fills the form, putting items into the by-item split.expense_attachmentstable holds up to five ordered files per expense in the privatereceiptsbucket. It has RLS throughmy_group_ids, adds theset_expense_attachmentsandcreate_expense_with_attachmentsRPCs (not callable by anon), and uses a trigger to keepreceipt_pathas the first attachment for older clients. On the web side, the old singlereceipt-fieldis replaced byattachments-fieldand anattachment-galleryviewer, attachment changes appear in expense history, and strings are added in en, de, es, fr and pt.receipts-sweepedge function and cron job delete bucket files older than a day that nothing references (orphan_receipts, service role only). The change also adds ADR 21,docs/receipts.mdand updates to architecture, security, sequences and deployment docs, plus pgTAP, unit and e2e tests.Risk Assessment
Testing
Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
apps/web/src/features/expenses/form/expense-form.tsx:290- When you edit an expense, the form treats a stored attachment list that is still loading or failed to load as empty. Any change made from that view replaces the real list.fileDraftsfalls back todraftsFromStored(storedFiles.data ?? []), so whilestoredFilesis pending or errored, AttachmentsField (line 862) shows no files with an Add button, and the scan's attach step builds on the same empty base (addDrafts(fileDrafts, [photo]), line 517). Example: the attachments fetch fails on an expense that has [a.jpg, b.pdf], and the user adds one photo or applies a scan.attachmentsbecomes [new]. At save,stored = storedFiles.data ?? [](line 392) is [].attachmentsChangedis true, soset_expense_attachmentswrites [new] and silently drops a and b. They then lose every reference, andorphan_receiptsdeletes them a day later. If the list was only slow and loaded before save,saveAttachmentsremoves a and b from storage right away (uploader or admin). Fix: untilstoredFiles.isSuccess, keep AttachmentsField and the scan's attach checkbox read-only, or skip the attachments write. Never treat a failed load as an empty list. Same invariant at line 517 (applyScan), line 653 (canAttachfrom an unloaded list) and line 392 (save readingstoredFiles.data ?? []).packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.apps/web/src/features/expenses/form/expense-form.tsx:297- The unmount cleanup deletes every entry inuploaded.current, even while the save mutation that uses those files is still running. Example: the user saves a new expense with a photo, and the upload finishes. Whilecreate_expense_with_attachmentsis in flight, the user navigates back. The cleanup removes the file. The RPC then either fails withattachment_not_foundafter the form is gone, or commits a row whose file no longer exists, which shows a broken thumbnail. React Query keeps running the mutation after unmount, so this path is reachable. Fix: skip or defer the cleanup whilesave.isPending, for example by deleting in the mutation'sonSettledwhen the component has unmounted.docs/security.md:43- Several new docs describe things that are no longer true. (1) docs/security.md:43 says a removed file not deleted by its uploader stays 'with nothing pointing at it', with no end date. The daily receipts-sweep now deletes it after a day, as ADR 21 says ('until the next sweep'). (2) docs/receipts.md:36 calls the functionprivate.orphan_receipts, but it ispublic.orphan_receipts(service_role only). (3) supabase/tests/29_expense_attachments.test.sql:1 cites migration20261113155139_expense_attachments, but the file was renamed to20261114015104_expense_attachments.sql.supabase/migrations/20261114015104_expense_attachments.sql:236- Simplification:private.apply_attachmentsaccepts each entry as either a string path or a{path}object. The only caller,setExpenseAttachments/createExpenseWithAttachmentsin packages/api, always sends a string array, and nothing in the intent needs a second format. The narrower form is strings only: drop thewhen 'object'branch so any non-string entry raisesinvalid_receipt_path. That is one less public API format to keep compatible under ADR 8 (additive migrations).🔧 Fix applied.
3 issues (2 warnings, 1 info) still open:
packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.packages/core/src/receipts/parse-receipt.ts:495- Round 1's fix (cash-tendered-as-total) moved the wrong total; it did not remove it. With payment lines kept out oflargest, a receipt with no TOTAL line now falls back to its most expensive item. Take "Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00":largest= 1400 (the Burger line), sototal= 1400.paid[2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, anditemsMatchis 'off'.defaultChoice(scan-fill.ts:28) then ticks the total and leaves the items unticked, so 'Fill in the expense' suggests 14.00 for a 19.00 bill without any error. Before round 1 it suggested 20.00. The same happens on any receipt whose total line OCR missed: the biggest priced item becomes the bill. The round-1 test (parse-receipt.test.ts:494) asserts onlynot.toBe(2000)and not exact/close, so it passes with the wrong 1400. It has no independent oracle; the receipt itself says the bill is 19.00. Fix: let thelargestfallback count only priced lines that did not become items (e.g. the bare '3.30' line in the existing 'falls back to the largest amount' test), or drop it when it is below the items sum. Leavetotalnull otherwise, so the items carry the amount (fillFromScan then gives 19.00). AsserttotalCentsis null or 1900 and the filled amount is 1900.docs/adr/0021-receipt-scanning-and-attachments.md:14- Two docs still describe the old flow. (1) ADR 21 line 14 says photos are shrunk to 2000 px JPEG 'before reading and uploading'. The same ADR (line 10), receipts.md and scan-receipt.tsx (readReceiptText(picked, ...), withprepareImageused only for upload) all say the reader works from the original photo. (2) docs/sequences.md:337 says a scanned expense is saved by 'the samecreate_expense_v2call'. An expense created with files goes throughcreate_expense_with_attachments(expense-form.tsx:472).🔧 Fix applied.
4 warnings still open:
packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.packages/core/src/receipts/parse-receipt.ts:495- Round 1's fix (cash-tendered-as-total) moved the wrong total; it did not remove it. With payment lines kept out oflargest, a receipt with no TOTAL line now falls back to its most expensive item. Take "Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00":largest= 1400 (the Burger line), sototal= 1400.paid[2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, anditemsMatchis 'off'.defaultChoice(scan-fill.ts:28) then ticks the total and leaves the items unticked, so 'Fill in the expense' suggests 14.00 for a 19.00 bill without any error. Before round 1 it suggested 20.00. The same happens on any receipt whose total line OCR missed: the biggest priced item becomes the bill. The round-1 test (parse-receipt.test.ts:494) asserts onlynot.toBe(2000)and not exact/close, so it passes with the wrong 1400. It has no independent oracle; the receipt itself says the bill is 19.00. Fix: let thelargestfallback count only priced lines that did not become items (e.g. the bare '3.30' line in the existing 'falls back to the largest amount' test), or drop it when it is below the items sum. Leavetotalnull otherwise, so the items carry the amount (fillFromScan then gives 19.00). AsserttotalCentsis null or 1900 and the filled amount is 1900.packages/core/src/receipts/parse-receipt.ts:517- Round 2's no-total fallback (if (total === null && offered.length > 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs whentotal !== null, so a VAT-included line is added on top. It also ignores a subtotal that was read. Concrete cases, all with the TOTAL line missed by OCR: (1) the UK_PUB fixture withoutTOTAL £31.85: items 31.85, andVAT @ 20% included £5.31is kind 'tax', so total = 37.16 and itemsMatch is 'exact'.Card £31.85is ignored becausepaidis only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE withoutTotal TTC:dont TVA 10% 2,75gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT withoutTOTAL: 77.00 + IVA incluido 7.00 + propina 5.00 = 89.00, while Tarjeta says 82.00. (4) 'Burger 14.00 / Subtotal 19.00 / Tax 1.52' with Fries and TOTAL missed: total 15.52 'exact', althoughsubtotal(19.00) was read andbasealready prefers it. Each case gives a wrong amount marked as an exact match, so nothing warns. Round 2 introduced this. The driver asked for 'their sum (plus any tax, tip or discount kept)', but nothing was reconciled to decide what to keep. Fix: when no total line was read, first try eachpaidamount as the total through the samesumsreconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise usebase(subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plusdont). Add the four no-total variants above as tests; the receipts' own TOTAL lines are the oracle.packages/core/src/receipts/parse-receipt.ts:517- When total is derived from the items (round 2),receiptItemsMatchcompares the items with their own sum, so every reading that missed the total line reports 'exact'. This breaks the intent's 'A far-off read gets a second and a capped third pass' and the 'items do not match' warning. In scan-receipt.tsx:108,enoughreturns true for such a first pass, so no second reading happens. Example: pass 1 reads only 'Burger 14.00' (Fries and TOTAL garbled). total = 1400 'exact', so the reader stops. The review shows no warning (scan-receipt.tsx:497 hides it for 'exact'), and both boxes are ticked, so the form fills 14.00 for a 19.00 bill. InbestScan(scan-fill.ts:39), this self-confirmed 'exact' (rank 3) also beats a later reading that found 'TOTAL 19.00' with items 18.90 ('close', rank 2). Before round 2, a missed total line usually scored 'off' or 'close' and triggered a retry. Fix: returnitemsMatch: nullwhen the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfyenough, anddefaultChoicestill ticks items (null !== 'off') and total, so the fill stays 19.00 for the Diner example. Test: parse the Diner text without the CASH line and check itemsMatch is null, andbestScanprefers a reading that has a TOTAL line.🔧 Fix applied.
6 issues (5 warnings, 1 info) still open:
packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.packages/core/src/receipts/parse-receipt.ts:495- Round 1's fix (cash-tendered-as-total) moved the wrong total; it did not remove it. With payment lines kept out oflargest, a receipt with no TOTAL line now falls back to its most expensive item. Take "Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00":largest= 1400 (the Burger line), sototal= 1400.paid[2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, anditemsMatchis 'off'.defaultChoice(scan-fill.ts:28) then ticks the total and leaves the items unticked, so 'Fill in the expense' suggests 14.00 for a 19.00 bill without any error. Before round 1 it suggested 20.00. The same happens on any receipt whose total line OCR missed: the biggest priced item becomes the bill. The round-1 test (parse-receipt.test.ts:494) asserts onlynot.toBe(2000)and not exact/close, so it passes with the wrong 1400. It has no independent oracle; the receipt itself says the bill is 19.00. Fix: let thelargestfallback count only priced lines that did not become items (e.g. the bare '3.30' line in the existing 'falls back to the largest amount' test), or drop it when it is below the items sum. Leavetotalnull otherwise, so the items carry the amount (fillFromScan then gives 19.00). AsserttotalCentsis null or 1900 and the filled amount is 1900.packages/core/src/receipts/parse-receipt.ts:517- Round 2's no-total fallback (if (total === null && offered.length > 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs whentotal !== null, so a VAT-included line is added on top. It also ignores a subtotal that was read. Concrete cases, all with the TOTAL line missed by OCR: (1) the UK_PUB fixture withoutTOTAL £31.85: items 31.85, andVAT @ 20% included £5.31is kind 'tax', so total = 37.16 and itemsMatch is 'exact'.Card £31.85is ignored becausepaidis only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE withoutTotal TTC:dont TVA 10% 2,75gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT withoutTOTAL: 77.00 + IVA incluido 7.00 + propina 5.00 = 89.00, while Tarjeta says 82.00. (4) 'Burger 14.00 / Subtotal 19.00 / Tax 1.52' with Fries and TOTAL missed: total 15.52 'exact', althoughsubtotal(19.00) was read andbasealready prefers it. Each case gives a wrong amount marked as an exact match, so nothing warns. Round 2 introduced this. The driver asked for 'their sum (plus any tax, tip or discount kept)', but nothing was reconciled to decide what to keep. Fix: when no total line was read, first try eachpaidamount as the total through the samesumsreconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise usebase(subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plusdont). Add the four no-total variants above as tests; the receipts' own TOTAL lines are the oracle.packages/core/src/receipts/parse-receipt.ts:517- When total is derived from the items (round 2),receiptItemsMatchcompares the items with their own sum, so every reading that missed the total line reports 'exact'. This breaks the intent's 'A far-off read gets a second and a capped third pass' and the 'items do not match' warning. In scan-receipt.tsx:108,enoughreturns true for such a first pass, so no second reading happens. Example: pass 1 reads only 'Burger 14.00' (Fries and TOTAL garbled). total = 1400 'exact', so the reader stops. The review shows no warning (scan-receipt.tsx:497 hides it for 'exact'), and both boxes are ticked, so the form fills 14.00 for a 19.00 bill. InbestScan(scan-fill.ts:39), this self-confirmed 'exact' (rank 3) also beats a later reading that found 'TOTAL 19.00' with items 18.90 ('close', rank 2). Before round 2, a missed total line usually scored 'off' or 'close' and triggered a retry. Fix: returnitemsMatch: nullwhen the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfyenough, anddefaultChoicestill ticks items (null !== 'off') and total, so the fill stays 19.00 for the Diner example. Test: parse the Diner text without the CASH line and check itemsMatch is null, andbestScanprefers a reading that has a TOTAL line.packages/core/src/receipts/parse-receipt.ts:523- Round 3 introduced this in its no-total fallback. When no total line is read,card[0], the first line matching CARD_PAID, becomes the total, andofferedthen drops every item dearer than that derived total (line 528). An item whose name contains a payment word is classified as 'payment' (PAYMENT regex at :189, from e283ac4) and lands incard/cashahead of the real tender line. Concrete case: a drugstore receiptCVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77with the TOTAL line missed by OCR gives card=[499,1477], so total = 4.99. Shampoo (6.49 > 4.99) is filtered out, Toothpaste was already cut by itemsEnd, and the review fills 4.99 for a 14.77 bill. Split tender (GIFT CARD 5.00thenVISA 14.00) fails the same way: the first, partial card line becomes the bill and drops items. Before round 3 the same receipt gave the items sum. The review now shows only the generic 'worked out' notice, so the wrong amount goes through with every box ticked. Smallest fix, inside the round 3 code: take the last card line (tender lines print at the bottom), or skip a card amount below the items already read. Do not apply the 'dearer than the bill' item filter when the total was derived rather than read, because a derived total cannot prove an item is a misread. Sibling sites: the same first-line pick forcash[0]/change[0]at :522. The root classification at :189 (card,^bar\b,credit) also ends the item block at an item such as 'Birthday Card' or 'Bar snacks' (itemsEnd at :477), cutting the items after it even when a TOTAL line is read. Test: the CVS example above with no TOTAL line should give 14.77 and keep Shampoo; the oracle is the VISA line.docs/receipts.md:23- docs/receipts.md says a second pass runs 'when the items do not come to the total'. Since round 3 (readingsEnough), a reading whose total was worked out (no total line) also always gets the second and third passes, and the ADR was updated to say so. receipts.md was left behind. Add one clause saying a derived total never counts as enough.🔧 Fix applied.
8 warnings still open:
packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.packages/core/src/receipts/parse-receipt.ts:495- Round 1's fix (cash-tendered-as-total) moved the wrong total; it did not remove it. With payment lines kept out oflargest, a receipt with no TOTAL line now falls back to its most expensive item. Take "Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00":largest= 1400 (the Burger line), sototal= 1400.paid[2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, anditemsMatchis 'off'.defaultChoice(scan-fill.ts:28) then ticks the total and leaves the items unticked, so 'Fill in the expense' suggests 14.00 for a 19.00 bill without any error. Before round 1 it suggested 20.00. The same happens on any receipt whose total line OCR missed: the biggest priced item becomes the bill. The round-1 test (parse-receipt.test.ts:494) asserts onlynot.toBe(2000)and not exact/close, so it passes with the wrong 1400. It has no independent oracle; the receipt itself says the bill is 19.00. Fix: let thelargestfallback count only priced lines that did not become items (e.g. the bare '3.30' line in the existing 'falls back to the largest amount' test), or drop it when it is below the items sum. Leavetotalnull otherwise, so the items carry the amount (fillFromScan then gives 19.00). AsserttotalCentsis null or 1900 and the filled amount is 1900.packages/core/src/receipts/parse-receipt.ts:517- Round 2's no-total fallback (if (total === null && offered.length > 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs whentotal !== null, so a VAT-included line is added on top. It also ignores a subtotal that was read. Concrete cases, all with the TOTAL line missed by OCR: (1) the UK_PUB fixture withoutTOTAL £31.85: items 31.85, andVAT @ 20% included £5.31is kind 'tax', so total = 37.16 and itemsMatch is 'exact'.Card £31.85is ignored becausepaidis only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE withoutTotal TTC:dont TVA 10% 2,75gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT withoutTOTAL: 77.00 + IVA incluido 7.00 + propina 5.00 = 89.00, while Tarjeta says 82.00. (4) 'Burger 14.00 / Subtotal 19.00 / Tax 1.52' with Fries and TOTAL missed: total 15.52 'exact', althoughsubtotal(19.00) was read andbasealready prefers it. Each case gives a wrong amount marked as an exact match, so nothing warns. Round 2 introduced this. The driver asked for 'their sum (plus any tax, tip or discount kept)', but nothing was reconciled to decide what to keep. Fix: when no total line was read, first try eachpaidamount as the total through the samesumsreconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise usebase(subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plusdont). Add the four no-total variants above as tests; the receipts' own TOTAL lines are the oracle.packages/core/src/receipts/parse-receipt.ts:517- When total is derived from the items (round 2),receiptItemsMatchcompares the items with their own sum, so every reading that missed the total line reports 'exact'. This breaks the intent's 'A far-off read gets a second and a capped third pass' and the 'items do not match' warning. In scan-receipt.tsx:108,enoughreturns true for such a first pass, so no second reading happens. Example: pass 1 reads only 'Burger 14.00' (Fries and TOTAL garbled). total = 1400 'exact', so the reader stops. The review shows no warning (scan-receipt.tsx:497 hides it for 'exact'), and both boxes are ticked, so the form fills 14.00 for a 19.00 bill. InbestScan(scan-fill.ts:39), this self-confirmed 'exact' (rank 3) also beats a later reading that found 'TOTAL 19.00' with items 18.90 ('close', rank 2). Before round 2, a missed total line usually scored 'off' or 'close' and triggered a retry. Fix: returnitemsMatch: nullwhen the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfyenough, anddefaultChoicestill ticks items (null !== 'off') and total, so the fill stays 19.00 for the Diner example. Test: parse the Diner text without the CASH line and check itemsMatch is null, andbestScanprefers a reading that has a TOTAL line.packages/core/src/receipts/parse-receipt.ts:523- Round 3 introduced this in its no-total fallback. When no total line is read,card[0], the first line matching CARD_PAID, becomes the total, andofferedthen drops every item dearer than that derived total (line 528). An item whose name contains a payment word is classified as 'payment' (PAYMENT regex at :189, from e283ac4) and lands incard/cashahead of the real tender line. Concrete case: a drugstore receiptCVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77with the TOTAL line missed by OCR gives card=[499,1477], so total = 4.99. Shampoo (6.49 > 4.99) is filtered out, Toothpaste was already cut by itemsEnd, and the review fills 4.99 for a 14.77 bill. Split tender (GIFT CARD 5.00thenVISA 14.00) fails the same way: the first, partial card line becomes the bill and drops items. Before round 3 the same receipt gave the items sum. The review now shows only the generic 'worked out' notice, so the wrong amount goes through with every box ticked. Smallest fix, inside the round 3 code: take the last card line (tender lines print at the bottom), or skip a card amount below the items already read. Do not apply the 'dearer than the bill' item filter when the total was derived rather than read, because a derived total cannot prove an item is a misread. Sibling sites: the same first-line pick forcash[0]/change[0]at :522. The root classification at :189 (card,^bar\b,credit) also ends the item block at an item such as 'Birthday Card' or 'Bar snacks' (itemsEnd at :477), cutting the items after it even when a TOTAL line is read. Test: the CVS example above with no TOTAL line should give 14.77 and keep Shampoo; the oracle is the VISA line.packages/core/src/receipts/parse-receipt.ts:191- Round 4 introduced this. It anchored PAYMENT to the start of the line, so a tender line whose first word is a payment verb is now read as an item. Before round 4 these lines were payment lines. I traced the current code: 'Cafe creme 3,30 / Croissant 27,00 / Paiement CB 30,30' (no total line) returns the items [Cafe creme, Croissant, 'Paiement CB' 30.30] and a total of 60.60, double the bill. 'Pago con tarjeta 82,00' does the same (164.00), and so does 'Amount Tendered 20.00 / Change 1.00' (39.00 with 'Amount Tendered' as an item). With a TOTAL line, 'Total 30,50 / Paiement CB 30,30' no longer reachespaid, so the 'card line confirms the total' correction at :555 stops working (result is 30.50 'close' where it used to be 30.30 'exact'). That correction is required by the intent: 'payment lines can confirm the total'. Sibling sites that depend on the same classification: CARD_PAID/CASH_PAID routing at :476-478 (the lines never get there), itemsEnd at :479 (the item block no longer ends at the tender line), and the derived total at :527. Smallest fix that keeps 'Birthday Card 4.99' and 'Bar snacks' as items: anchor only the words that also name products (card, gift card, bar, credit, ec, change, points, balance, paid, payment). Let unambiguous tender words (cb, tarjeta, cartao, tendered, kartenzahlung, girocard, efectivo, especes, dinheiro, wechselgeld, ruckgeld, rendu, monnaie, troco, cambio) match anywhere, or allow a payment lead-in (paiement, pago (con), pagamento, paye (par), bezahlt, zahlung, amount) before the anchored word. Add tests for 'Paiement CB 30,30', 'Pago con tarjeta 82,00' and 'Amount Tendered 20.00' (oracle: the receipt's own items add up to the tendered or card amount).packages/core/src/receipts/parse-receipt.ts:527- Round 4 introduced this. The derived total now adds up every line that CARD_PAID matches, and that includes a card's remaining-balance line, which split-tender gift card receipts print. I traced it: 'Shirt 12.00 / Socks 7.00 / GIFT CARD 5.00 / VISA 14.00 / GIFT CARD BALANCE 20.00' with the TOTAL line lost returns a total of 39.00 for a 19.00 bill. The :555 fallback does not catch it, because the 19.00 sum of the items is not any single paid line. Fix: at :477, do not push remaining-balance lines (balance, bal, remaining, solde, saldo, restguthaben, restbetrag) intocard. Test with the receipt above (oracle: 12.00 + 7.00 = 5.00 + 14.00 = 19.00).packages/core/src/receipts/parse-receipt.ts:395- Round 3 introduced this, and round 4's wider derived totals make it worse. For a derived total, receiptItemsMatch returns 'unconfirmed' without comparing the items to the total. As a result, defaultChoice (apps/web/src/features/expenses/receipts/scan-fill.ts:28) ticks the items even when they are far from the total, scan-receipt.tsx:505 hides the 'items do not come to the total' warning, and filling the form adds the gap as an 'Everything else' line. Example: in the gift card balance case above, items 19.00 against a derived 39.00 go through with every box ticked and a phantom 20.00 line, with only the generic 'total was worked out' notice. A derived total that comes from payment lines is independent evidence, so a mismatch is exactly what the warning exists to show. Fix without touching readingsEnough/bestScan (they can keep keying off totalDerived): in the review and in defaultChoice, also compare the items with the derived total (scanItemsTotal vs totalCents), untick the items and show the mismatch warning when they are far off. Test: a derived total of 39.00 with items of 19.00 starts with the items unticked and shows the warning.🔧 Fix applied.
9 warnings still open:
packages/core/src/receipts/parse-receipt.ts:431-largestcounts payment lines, so when OCR misses the TOTAL line, the cash tendered becomes the bill. Example text: "Burger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00".totalstays null and falls back tolargest= 2000.paidis [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it.receiptItemsMatchreturns 'close' (diff 100 = 5% of 2000), so both items and total start ticked.fillFromScanthen adds an 'Everything else' line of 1.00 and suggests 20.00 instead of 19.00, with no error. The intent says payment lines can confirm a total, not supply one. Fix: leave payment lines out oflargest(update it only for non-payment kinds), and let a payment line set the total only when it equals the items sum.packages/core/src/receipts/parse-receipt.ts:495- Round 1's fix (cash-tendered-as-total) moved the wrong total; it did not remove it. With payment lines kept out oflargest, a receipt with no TOTAL line now falls back to its most expensive item. Take "Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00":largest= 1400 (the Burger line), sototal= 1400.paid[2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, anditemsMatchis 'off'.defaultChoice(scan-fill.ts:28) then ticks the total and leaves the items unticked, so 'Fill in the expense' suggests 14.00 for a 19.00 bill without any error. Before round 1 it suggested 20.00. The same happens on any receipt whose total line OCR missed: the biggest priced item becomes the bill. The round-1 test (parse-receipt.test.ts:494) asserts onlynot.toBe(2000)and not exact/close, so it passes with the wrong 1400. It has no independent oracle; the receipt itself says the bill is 19.00. Fix: let thelargestfallback count only priced lines that did not become items (e.g. the bare '3.30' line in the existing 'falls back to the largest amount' test), or drop it when it is below the items sum. Leavetotalnull otherwise, so the items carry the amount (fillFromScan then gives 19.00). AsserttotalCentsis null or 1900 and the filled amount is 1900.packages/core/src/receipts/parse-receipt.ts:517- Round 2's no-total fallback (if (total === null && offered.length > 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs whentotal !== null, so a VAT-included line is added on top. It also ignores a subtotal that was read. Concrete cases, all with the TOTAL line missed by OCR: (1) the UK_PUB fixture withoutTOTAL £31.85: items 31.85, andVAT @ 20% included £5.31is kind 'tax', so total = 37.16 and itemsMatch is 'exact'.Card £31.85is ignored becausepaidis only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE withoutTotal TTC:dont TVA 10% 2,75gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT withoutTOTAL: 77.00 + IVA incluido 7.00 + propina 5.00 = 89.00, while Tarjeta says 82.00. (4) 'Burger 14.00 / Subtotal 19.00 / Tax 1.52' with Fries and TOTAL missed: total 15.52 'exact', althoughsubtotal(19.00) was read andbasealready prefers it. Each case gives a wrong amount marked as an exact match, so nothing warns. Round 2 introduced this. The driver asked for 'their sum (plus any tax, tip or discount kept)', but nothing was reconciled to decide what to keep. Fix: when no total line was read, first try eachpaidamount as the total through the samesumsreconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise usebase(subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plusdont). Add the four no-total variants above as tests; the receipts' own TOTAL lines are the oracle.packages/core/src/receipts/parse-receipt.ts:517- When total is derived from the items (round 2),receiptItemsMatchcompares the items with their own sum, so every reading that missed the total line reports 'exact'. This breaks the intent's 'A far-off read gets a second and a capped third pass' and the 'items do not match' warning. In scan-receipt.tsx:108,enoughreturns true for such a first pass, so no second reading happens. Example: pass 1 reads only 'Burger 14.00' (Fries and TOTAL garbled). total = 1400 'exact', so the reader stops. The review shows no warning (scan-receipt.tsx:497 hides it for 'exact'), and both boxes are ticked, so the form fills 14.00 for a 19.00 bill. InbestScan(scan-fill.ts:39), this self-confirmed 'exact' (rank 3) also beats a later reading that found 'TOTAL 19.00' with items 18.90 ('close', rank 2). Before round 2, a missed total line usually scored 'off' or 'close' and triggered a retry. Fix: returnitemsMatch: nullwhen the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfyenough, anddefaultChoicestill ticks items (null !== 'off') and total, so the fill stays 19.00 for the Diner example. Test: parse the Diner text without the CASH line and check itemsMatch is null, andbestScanprefers a reading that has a TOTAL line.packages/core/src/receipts/parse-receipt.ts:523- Round 3 introduced this in its no-total fallback. When no total line is read,card[0], the first line matching CARD_PAID, becomes the total, andofferedthen drops every item dearer than that derived total (line 528). An item whose name contains a payment word is classified as 'payment' (PAYMENT regex at :189, from e283ac4) and lands incard/cashahead of the real tender line. Concrete case: a drugstore receiptCVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77with the TOTAL line missed by OCR gives card=[499,1477], so total = 4.99. Shampoo (6.49 > 4.99) is filtered out, Toothpaste was already cut by itemsEnd, and the review fills 4.99 for a 14.77 bill. Split tender (GIFT CARD 5.00thenVISA 14.00) fails the same way: the first, partial card line becomes the bill and drops items. Before round 3 the same receipt gave the items sum. The review now shows only the generic 'worked out' notice, so the wrong amount goes through with every box ticked. Smallest fix, inside the round 3 code: take the last card line (tender lines print at the bottom), or skip a card amount below the items already read. Do not apply the 'dearer than the bill' item filter when the total was derived rather than read, because a derived total cannot prove an item is a misread. Sibling sites: the same first-line pick forcash[0]/change[0]at :522. The root classification at :189 (card,^bar\b,credit) also ends the item block at an item such as 'Birthday Card' or 'Bar snacks' (itemsEnd at :477), cutting the items after it even when a TOTAL line is read. Test: the CVS example above with no TOTAL line should give 14.77 and keep Shampoo; the oracle is the VISA line.packages/core/src/receipts/parse-receipt.ts:191- Round 4 introduced this. It anchored PAYMENT to the start of the line, so a tender line whose first word is a payment verb is now read as an item. Before round 4 these lines were payment lines. I traced the current code: 'Cafe creme 3,30 / Croissant 27,00 / Paiement CB 30,30' (no total line) returns the items [Cafe creme, Croissant, 'Paiement CB' 30.30] and a total of 60.60, double the bill. 'Pago con tarjeta 82,00' does the same (164.00), and so does 'Amount Tendered 20.00 / Change 1.00' (39.00 with 'Amount Tendered' as an item). With a TOTAL line, 'Total 30,50 / Paiement CB 30,30' no longer reachespaid, so the 'card line confirms the total' correction at :555 stops working (result is 30.50 'close' where it used to be 30.30 'exact'). That correction is required by the intent: 'payment lines can confirm the total'. Sibling sites that depend on the same classification: CARD_PAID/CASH_PAID routing at :476-478 (the lines never get there), itemsEnd at :479 (the item block no longer ends at the tender line), and the derived total at :527. Smallest fix that keeps 'Birthday Card 4.99' and 'Bar snacks' as items: anchor only the words that also name products (card, gift card, bar, credit, ec, change, points, balance, paid, payment). Let unambiguous tender words (cb, tarjeta, cartao, tendered, kartenzahlung, girocard, efectivo, especes, dinheiro, wechselgeld, ruckgeld, rendu, monnaie, troco, cambio) match anywhere, or allow a payment lead-in (paiement, pago (con), pagamento, paye (par), bezahlt, zahlung, amount) before the anchored word. Add tests for 'Paiement CB 30,30', 'Pago con tarjeta 82,00' and 'Amount Tendered 20.00' (oracle: the receipt's own items add up to the tendered or card amount).packages/core/src/receipts/parse-receipt.ts:527- Round 4 introduced this. The derived total now adds up every line that CARD_PAID matches, and that includes a card's remaining-balance line, which split-tender gift card receipts print. I traced it: 'Shirt 12.00 / Socks 7.00 / GIFT CARD 5.00 / VISA 14.00 / GIFT CARD BALANCE 20.00' with the TOTAL line lost returns a total of 39.00 for a 19.00 bill. The :555 fallback does not catch it, because the 19.00 sum of the items is not any single paid line. Fix: at :477, do not push remaining-balance lines (balance, bal, remaining, solde, saldo, restguthaben, restbetrag) intocard. Test with the receipt above (oracle: 12.00 + 7.00 = 5.00 + 14.00 = 19.00).packages/core/src/receipts/parse-receipt.ts:395- Round 3 introduced this, and round 4's wider derived totals make it worse. For a derived total, receiptItemsMatch returns 'unconfirmed' without comparing the items to the total. As a result, defaultChoice (apps/web/src/features/expenses/receipts/scan-fill.ts:28) ticks the items even when they are far from the total, scan-receipt.tsx:505 hides the 'items do not come to the total' warning, and filling the form adds the gap as an 'Everything else' line. Example: in the gift card balance case above, items 19.00 against a derived 39.00 go through with every box ticked and a phantom 20.00 line, with only the generic 'total was worked out' notice. A derived total that comes from payment lines is independent evidence, so a mismatch is exactly what the warning exists to show. Fix without touching readingsEnough/bestScan (they can keep keying off totalDerived): in the review and in defaultChoice, also compare the items with the derived total (scanItemsTotal vs totalCents), untick the items and show the mismatch warning when they are far off. Test: a derived total of 39.00 with items of 19.00 starts with the items unticked and shows the warning.packages/core/src/receipts/parse-receipt.ts:469- Round 5 put the raw cash-tendered line back intopaid(line 469: every payment line that is not change goes intopaid, cash included), and both total checks readpaiddirectly. When a change line was read, the cash handed over is not what was paid, but it can still override or confirm a total. Example: Burger 14.00, Fries 5.00 OCR-misread as 6.00, TOTAL 19.00, CASH 20.00, CHANGE 1.00. formSum is 20.00,paidis [2000], so line 540 replaces the correctly read 19.00 with 20.00. itemsMatch comes back 'exact', readingsEnough stops the reader, and the review shows no warning, even though cash minus change (19.00) contradicts it. Without the TOTAL line, line 546 confirms the derived 20.00 the same way (totalDerived=false, 'exact', no notice). Other places that must change with it: line 540 (read-total correction), line 546 (derived-total confirmation), and lines 469-470 (wherepaid/cashare filled). Balance, points, loyalty, auth and approval lines also land inpaid(e.g. 'GIFT CARD BALANCE 20.00'), so they can confirm or replace a total the same way. Fix: once a change line is read, count cash lines only through handedOver (cash minus change), not aspaidentries, and keep balance/points/loyalty/auth/approval lines out ofpaid. The 'Amount Tendered 20.00 / Change 1.00' and Diner tests still pass through handedOver. Add a test for the case above that expects total 19.00 with itemsMatch 'off'.🔧 Fix applied.
1 warning still open:
apps/web/src/features/expenses/receipts/scan-receipt.tsx:389- When no total or subtotal line is read, the parser sets the total to the sum of the items (from round 2, 750834e, kept by round 5). The review's edit() does not recompute that total after an item price is corrected. Example: receipt 'Diner / Burger 14.00 / Fries 6.00' with the TOTAL line lost gives total 20.00, totalDerived=true, itemsMatch 'unconfirmed'. The person corrects Fries to 5.00 against the photo. totalCents stays 2000. receiptItemsMatch puts the 1.00 gap within 5%, so it reports 'unconfirmed' and no mismatch warning shows (the warning skips 'unconfirmed'). Apply with the default choice (total and items ticked): fillFromScan (scan-fill.ts:108-114) computes missing = 2000 - 1900 = 100, which passes the 1% check, so it adds an 'Everything else' 1.00 line and saves the expense as 20.00. The person's correction is silently undone by a total that was never printed. Fix: in edit(), when scan.totalDerived and scan.subtotalCents === null (the total came from the items, so tax, tip and discount are null), set totalCents to the edited items' sum. Sibling site: scan-fill.ts:110 adds the 'Everything else' line from that stale total. Add a receipts.test.tsx case that edits a price on a derived scan and checks the filled amount.🔧 Fix applied.
1 info still open:
apps/web/src/features/expenses/receipts/use-zoom-pan.ts:146-steppasses the unclamped target scale tozoomAtand letsclampViewclamp only the scale afterwards, so x/y are scaled by the unclamped ratio. Example: zoomed to 4x with x=100 in a 400px frame, pressing '+' (the keyboard path; the button is disabled at max) gives zoomAt(..., 6, 0, 0) and x=150. The scale stays at 4 but the photo jumps sideways. The pinch path (line 104) already clamps the scale before calling zoomAt. Fix: clampview.scale * factorto [1, MAX_ZOOM] before zoomAt instep. That also covers zoomIn/zoomOut, which go through the same function.⏭️ **Test** - skipped
Step was skipped.
✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.