Skip to content

feat(web): scan receipts and attach several files per expense - #33

Merged
musa-kal merged 29 commits into
mainfrom
feat/receipts
Nov 14, 2026
Merged

musa-kal merged 29 commits into
mainfrom
feat/receipts

Conversation

@musa-kal

@musa-kal musa-kal commented Nov 14, 2026 •

Copy link
Copy Markdown
Member

Intent

Splitwise Pro parity: receipt scanning and several attachments per expense (ADR 21).

  • OCR runs in the browser (tesseract.js, pinned, loaded only by the scan button, files served from /ocr//), so no receipt leaves the device for reading. A far-off read gets a second and a capped third (colour) pass.
  • packages/core parseReceipt turns OCR text into merchant, date, currency, total and items, keeping tax, tip and discount only when they make it add up; payment lines can confirm the total.
  • A scan is a suggestion: a review sheet shows the photo (zoom, pan, keyboard), every part is editable, and "Fill in the expense" fills the form, items going into the by-item split.
  • expense_attachments holds up to five files per expense in order in the private receipts bucket; receipt_path stays the first attachment for older clients; RLS through my_group_ids so deleted groups' files vanish with them.
  • No orphaned files: retried saves reuse uploads, abandoned ones are deleted, and a daily receipts-sweep edge function plus cron removes unreferenced files older than a day.
    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

  • Receipt scanning in the browser: a scan button on the expense form runs OCR with tesseract.js, which is pinned, loaded only on demand and served from /ocr/<version>/. When the first pass is far off, it runs a second pass and at most one more in colour. The new parseReceipt in packages/core turns 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.
  • Several attachments per expense: the new expense_attachments table holds up to five ordered files per expense in the private receipts bucket. It has RLS through my_group_ids, adds the set_expense_attachments and create_expense_with_attachments RPCs (not callable by anon), and uses a trigger to keep receipt_path as the first attachment for older clients. On the web side, the old single receipt-field is replaced by attachments-field and an attachment-gallery viewer, attachment changes appear in expense history, and strings are added in en, de, es, fr and pt.
  • No orphaned files: retried saves reuse their uploads and abandoned uploads are deleted when the form closes. A daily receipts-sweep edge 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.md and updates to architecture, security, sequences and deployment docs, plus pgTAP, unit and e2e tests.

Risk Assessment

⚠️ Medium: I found no new defects. The round 7 fix works as intended: a total worked out from the items now changes when an item price is edited, and fillFromScan adds an "Everything else" line only when the total was printed on the receipt. Its regression test fails without the fix. Risk stays medium because the change is large: a new table, RPCs, a storage-deleting cron sweep, and a heuristic receipt parser whose edge cases needed several rounds of product decisions.

Testing

  • ⏭️ Test - skipped

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ 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. fileDrafts falls back to draftsFromStored(storedFiles.data ?? []), so while storedFiles is 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. attachments becomes [new]. At save, stored = storedFiles.data ?? [] (line 392) is []. attachmentsChanged is true, so set_expense_attachments writes [new] and silently drops a and b. They then lose every reference, and orphan_receipts deletes them a day later. If the list was only slow and loaded before save, saveAttachments removes a and b from storage right away (uploader or admin). Fix: until storedFiles.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 (canAttach from an unloaded list) and line 392 (save reading storedFiles.data ?? []).
  • ⚠️ packages/core/src/receipts/parse-receipt.ts:431 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 in uploaded.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. While create_expense_with_attachments is in flight, the user navigates back. The cleanup removes the file. The RPC then either fails with attachment_not_found after 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 while save.isPending, for example by deleting in the mutation's onSettled when 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 function private.orphan_receipts, but it is public.orphan_receipts (service_role only). (3) supabase/tests/29_expense_attachments.test.sql:1 cites migration 20261113155139_expense_attachments, but the file was renamed to 20261114015104_expense_attachments.sql.
  • ⚠️ supabase/migrations/20261114015104_expense_attachments.sql:236 - Simplification: private.apply_attachments accepts each entry as either a string path or a {path} object. The only caller, setExpenseAttachments / createExpenseWithAttachments in packages/api, always sends a string array, and nothing in the intent needs a second format. The narrower form is strings only: drop the when &#39;object&#39; branch so any non-string entry raises invalid_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 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 of largest, 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), so total = 1400. paid [2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, and itemsMatch is '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 only not.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 the largest fallback 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. Leave total null otherwise, so the items carry the amount (fillFromScan then gives 19.00). Assert totalCents is 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, ...), with prepareImage used 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 same create_expense_v2 call'. An expense created with files goes through create_expense_with_attachments (expense-form.tsx:472).

🔧 Fix applied.
4 warnings still open:

  • ⚠️ packages/core/src/receipts/parse-receipt.ts:431 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 of largest, 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), so total = 1400. paid [2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, and itemsMatch is '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 only not.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 the largest fallback 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. Leave total null otherwise, so the items carry the amount (fillFromScan then gives 19.00). Assert totalCents is 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 &amp;&amp; offered.length &gt; 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs when total !== 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 without TOTAL £31.85: items 31.85, and VAT @ 20% included £5.31 is kind 'tax', so total = 37.16 and itemsMatch is 'exact'. Card £31.85 is ignored because paid is only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE without Total TTC: dont TVA 10% 2,75 gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT without TOTAL: 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', although subtotal (19.00) was read and base already 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 each paid amount as the total through the same sums reconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise use base (subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plus dont). 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), receiptItemsMatch compares 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, enough returns 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. In bestScan (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: return itemsMatch: null when the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfy enough, and defaultChoice still 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, and bestScan prefers 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 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 of largest, 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), so total = 1400. paid [2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, and itemsMatch is '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 only not.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 the largest fallback 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. Leave total null otherwise, so the items carry the amount (fillFromScan then gives 19.00). Assert totalCents is 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 &amp;&amp; offered.length &gt; 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs when total !== 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 without TOTAL £31.85: items 31.85, and VAT @ 20% included £5.31 is kind 'tax', so total = 37.16 and itemsMatch is 'exact'. Card £31.85 is ignored because paid is only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE without Total TTC: dont TVA 10% 2,75 gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT without TOTAL: 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', although subtotal (19.00) was read and base already 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 each paid amount as the total through the same sums reconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise use base (subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plus dont). 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), receiptItemsMatch compares 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, enough returns 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. In bestScan (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: return itemsMatch: null when the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfy enough, and defaultChoice still 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, and bestScan prefers 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, and offered then 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 in card/cash ahead of the real tender line. Concrete case: a drugstore receipt CVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77 with 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.00 then VISA 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 for cash[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 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 of largest, 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), so total = 1400. paid [2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, and itemsMatch is '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 only not.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 the largest fallback 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. Leave total null otherwise, so the items carry the amount (fillFromScan then gives 19.00). Assert totalCents is 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 &amp;&amp; offered.length &gt; 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs when total !== 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 without TOTAL £31.85: items 31.85, and VAT @ 20% included £5.31 is kind 'tax', so total = 37.16 and itemsMatch is 'exact'. Card £31.85 is ignored because paid is only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE without Total TTC: dont TVA 10% 2,75 gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT without TOTAL: 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', although subtotal (19.00) was read and base already 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 each paid amount as the total through the same sums reconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise use base (subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plus dont). 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), receiptItemsMatch compares 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, enough returns 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. In bestScan (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: return itemsMatch: null when the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfy enough, and defaultChoice still 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, and bestScan prefers 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, and offered then 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 in card/cash ahead of the real tender line. Concrete case: a drugstore receipt CVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77 with 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.00 then VISA 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 for cash[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 reaches paid, 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) into card. 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 - largest counts 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". total stays null and falls back to largest = 2000. paid is [2000, 100], which does not contain formSum 1900, so the confirm step at line 517 does not correct it. receiptItemsMatch returns 'close' (diff 100 = 5% of 2000), so both items and total start ticked. fillFromScan then 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 of largest (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 of largest, 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), so total = 1400. paid [2000, 100] does not contain formSum 1900, so the confirm step at line 512 does not correct it, and itemsMatch is '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 only not.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 the largest fallback 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. Leave total null otherwise, so the items carry the amount (fillFromScan then gives 19.00). Assert totalCents is 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 &amp;&amp; offered.length &gt; 0) total = formSum) adds tax, tip and discount lines that were never reconciled. The reconciliation block above it only runs when total !== 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 without TOTAL £31.85: items 31.85, and VAT @ 20% included £5.31 is kind 'tax', so total = 37.16 and itemsMatch is 'exact'. Card £31.85 is ignored because paid is only checked against formSum (37.16). fillFromScan then puts taxCents 531 in the draft and fills 37.16. (2) FR_CAFE without Total TTC: dont TVA 10% 2,75 gives 33.05 although the CB line says 30.30. (3) ES_RESTAURANT without TOTAL: 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', although subtotal (19.00) was read and base already 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 each paid amount as the total through the same sums reconciliation (base + subsets of tax/tip/discount), and take the first that matches. Otherwise use base (subtotal ?? itemsSum), and add a tax line only when it is not marked inclusive (INCLUSIVE, plus dont). 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), receiptItemsMatch compares 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, enough returns 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. In bestScan (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: return itemsMatch: null when the total was derived rather than read or confirmed by a matching payment line. Null ranks 0 in bestScan, does not satisfy enough, and defaultChoice still 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, and bestScan prefers 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, and offered then 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 in card/cash ahead of the real tender line. Concrete case: a drugstore receipt CVS / Shampoo 6.49 / Birthday Card 4.99 / Toothpaste 3.29 / VISA 14.77 with 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.00 then VISA 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 for cash[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 reaches paid, 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) into card. 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 into paid (line 469: every payment line that is not change goes into paid, cash included), and both total checks read paid directly. 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, paid is [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 (where paid/cash are filled). Balance, points, loyalty, auth and approval lines also land in paid (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 as paid entries, and keep balance/points/loyalty/auth/approval lines out of paid. 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 - step passes the unclamped target scale to zoomAt and lets clampView clamp 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: clamp view.scale * factor to [1, MAX_ZOOM] before zoomAt in step. 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.

musa-kal and others added 27 commits November 13, 2026 20:48
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
musa-kal merged commit 57fe62b into main Nov 14, 2026
7 checks passed
@musa-kal
musa-kal deleted the feat/receipts branch November 14, 2026 05:02

This branch was successfully deployed

1 active deployment
preview — f82d0fb0 Deployed Nov 14, 2026 by musa-kal via Deploy web to Cloudflare #91
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants