From 80121e3d54f1c4567527be2859e51642c7360073 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:51:13 -0500 Subject: [PATCH 01/29] read receipt text into a total, date, merchant and items 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> --- packages/core/src/index.ts | 1 + packages/core/src/receipts/index.ts | 9 + .../core/src/receipts/parse-receipt.test.ts | 386 +++++++++++++++ packages/core/src/receipts/parse-receipt.ts | 445 ++++++++++++++++++ 4 files changed, 841 insertions(+) create mode 100644 packages/core/src/receipts/index.ts create mode 100644 packages/core/src/receipts/parse-receipt.test.ts create mode 100644 packages/core/src/receipts/parse-receipt.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3752426..fe9c6f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export * from './money' export * from './payments' export * from './people' export * from './portability' +export * from './receipts' export * from './recurrence' export * from './roles' export * from './schemas' diff --git a/packages/core/src/receipts/index.ts b/packages/core/src/receipts/index.ts new file mode 100644 index 0000000..0c32698 --- /dev/null +++ b/packages/core/src/receipts/index.ts @@ -0,0 +1,9 @@ +export { + MAX_RECEIPT_ITEMS, + parseReceipt, + parseReceiptAmount, + receiptDateOrder, + type ParseReceiptOptions, + type ReceiptItem, + type ReceiptScan, +} from './parse-receipt' diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts new file mode 100644 index 0000000..1519eae --- /dev/null +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_RECEIPT_ITEMS, + parseReceipt, + parseReceiptAmount, + receiptDateOrder, + type ReceiptItem, +} from '../index' + +// Text as an OCR engine hands it back: uneven spacing, stray marks, lines that are not items. + +const US_DINER = `JOE'S DINER +123 Main Street +Springfield, IL 62701 +Tel (217) 555-0142 +Server: Maria Table 12 +11/08/2026 7:42 PM +2 Cheeseburger 25.00 +Caesar Salad 11.50 +1 Iced Tea 3.25 +Fries 4.75 +SUBTOTAL 44.50 +Sales Tax 8.25% 3.67 +TIP 8.00 +TOTAL 56.17 +VISA ****4821 56.17 +THANK YOU!` + +const UK_PUB = `THE RED LION +High Street, Oxford OX1 4AA +VAT No. 123 4567 89 +Date: 07/11/2026 Time: 21:15 +Pint Lager £5.20 +Pint Lager £5.20 +Fish & Chips £14.95 +Sticky Toffee Pud £6.50 +TOTAL £31.85 +Card £31.85 +VAT @ 20% included £5.31` + +const DE_SUPERMARKET = `REWE Markt GmbH +Hauptstr. 5 +10115 Berlin +UID Nr.: DE123456789 +EUR +BIO VOLLMILCH 1,19 B +BROT ROGGEN 2,49 B +APFEL BRAEBURN + 2 x 1,29 2,58 B +PFAND 0,25 A +LEERGUT -0,25 A +-------------------------------- +SUMME EUR 6,26 +Geg. BAR EUR 10,00 +Rückgeld BAR EUR 3,74 +Steuer % Netto Steuer Brutto +B= 7,0% 5,85 0,41 6,26 +13.11.2026 18:04 Bon-Nr.:4211` + +const FR_CAFE = `Café de Flore +172 Bd Saint-Germain +75006 Paris +Table 4 - Couverts 2 +Le 12/11/2026 à 13:05 +2 Café crème 9,80 € +1 Croque-monsieur 14,50 € +1 Eau minérale 6,00 € +Total TTC 30,30 € +dont TVA 10% 2,75 € +CB 30,30 € +Merci de votre visite` + +const ES_RESTAURANT = `RESTAURANTE CASA LUCIO +C/ Cava Baja 35, Madrid +NIF B-12345678 +Fecha: 9 de noviembre de 2026 +Mesa 7 +Huevos rotos x2 28,00 +Jamón ibérico 24,50 +Vino tinto (botella) 22,00 +Pan 2,50 +SUBTOTAL 77,00 +Propina 5,00 +TOTAL 82,00 +IVA incluido (10%) 7,00 +Tarjeta 82,00` + +const BR_BAKERY = `PADARIA REAL +CNPJ 12.345.678/0001-90 +Data: 10/11/2026 +Pão de queijo 3 x 4,50 13,50 +Café expresso 6,00 +Suco de laranja 9,90 +Valor total R$ 29,40 +Dinheiro R$ 50,00 +Troco R$ 20,60` + +const JP_KONBINI = `FamilyMart Shibuya +2026/11/05 08:12 +Onigiri Salmon ¥150 +Green Tea ¥140 +Coffee M ¥180 +Subtotal ¥470 +Tax (8%) ¥37 +Total ¥507 +Cash ¥1,000 +Change ¥493` + +const items = (list: ReceiptItem[]) => list.map((i) => [i.description, i.amountCents, i.quantity]) + +describe('parseReceipt', () => { + it('reads a US diner bill with tax and tip on top', () => { + const scan = parseReceipt(US_DINER, { currency: 'USD', dateOrder: 'mdy' }) + expect(scan).toEqual({ + merchant: "Joe's Diner", + date: '2026-11-08', + currency: null, + totalCents: 5617, + subtotalCents: 4450, + taxCents: 367, + tipCents: 800, + discountCents: null, + items: [ + { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, + { description: 'Caesar Salad', amountCents: 1150, quantity: 1 }, + { description: 'Iced Tea', amountCents: 325, quantity: 1 }, + { description: 'Fries', amountCents: 475, quantity: 1 }, + ], + }) + }) + + it('reads a UK pub tab where VAT is already in the prices', () => { + const scan = parseReceipt(UK_PUB, { currency: 'EUR' }) + expect(scan.currency).toBe('GBP') + expect(scan.merchant).toBe('The Red Lion') + expect(scan.date).toBe('2026-11-07') + expect(scan.totalCents).toBe(3185) + expect(scan.taxCents).toBeNull() + expect(items(scan.items)).toEqual([ + ['Pint Lager', 520, 1], + ['Pint Lager', 520, 1], + ['Fish & Chips', 1495, 1], + ['Sticky Toffee Pud', 650, 1], + ]) + }) + + it('reads a German till roll: decimal commas, class letters, a quantity line and a deposit back', () => { + const scan = parseReceipt(DE_SUPERMARKET, { currency: 'EUR' }) + expect(scan.currency).toBe('EUR') + expect(scan.merchant).toBe('REWE Markt GmbH') + expect(scan.date).toBe('2026-11-13') + expect(scan.totalCents).toBe(626) + expect(scan.discountCents).toBe(25) + expect(scan.taxCents).toBeNull() + expect(items(scan.items)).toEqual([ + ['Bio Vollmilch', 119, 1], + ['Brot Roggen', 249, 1], + ['Apfel Braeburn', 258, 2], + ['Pfand', 25, 1], + ]) + }) + + it('reads a French café bill with the total TTC and the VAT it includes', () => { + const scan = parseReceipt(FR_CAFE, { currency: 'EUR', dateOrder: 'mdy' }) + expect(scan.merchant).toBe('Café de Flore') + // A euro receipt is day first even for a viewer whose own dates are month first. + expect(scan.date).toBe('2026-11-12') + expect(scan.totalCents).toBe(3030) + expect(scan.taxCents).toBeNull() + expect(items(scan.items)).toEqual([ + ['Café crème', 980, 2], + ['Croque-monsieur', 1450, 1], + ['Eau minérale', 600, 1], + ]) + }) + + it('reads a Spanish bill with a written date, a tip and IVA included', () => { + const scan = parseReceipt(ES_RESTAURANT, { currency: 'EUR' }) + expect(scan.currency).toBeNull() + expect(scan.merchant).toBe('Restaurante Casa Lucio') + expect(scan.date).toBe('2026-11-09') + expect(scan.subtotalCents).toBe(7700) + expect(scan.tipCents).toBe(500) + expect(scan.taxCents).toBeNull() + expect(scan.totalCents).toBe(8200) + expect(items(scan.items)).toEqual([ + ['Huevos rotos', 2800, 2], + ['Jamón ibérico', 2450, 1], + ['Vino tinto (botella)', 2200, 1], + ['Pan', 250, 1], + ]) + }) + + it('reads a Brazilian bakery receipt in reais with a unit price on the line', () => { + const scan = parseReceipt(BR_BAKERY, { currency: 'EUR' }) + expect(scan.currency).toBe('BRL') + expect(scan.merchant).toBe('Padaria Real') + expect(scan.date).toBe('2026-11-10') + expect(scan.totalCents).toBe(2940) + expect(items(scan.items)).toEqual([ + ['Pão de queijo', 1350, 3], + ['Café expresso', 600, 1], + ['Suco de laranja', 990, 1], + ]) + }) + + it('reads yen without decimals and consumption tax on top', () => { + const scan = parseReceipt(JP_KONBINI, { currency: 'USD', dateOrder: 'mdy' }) + expect(scan.currency).toBe('JPY') + expect(scan.merchant).toBe('FamilyMart Shibuya') + expect(scan.date).toBe('2026-11-05') + expect(scan.totalCents).toBe(507) + expect(scan.subtotalCents).toBe(470) + expect(scan.taxCents).toBe(37) + expect(items(scan.items)).toEqual([ + ['Onigiri Salmon', 150, 1], + ['Green Tea', 140, 1], + ['Coffee M', 180, 1], + ]) + }) + + it('falls back to the largest amount when no line says total', () => { + const scan = parseReceipt('Corner Shop\nMilk 1.20\nBread 2.10\n3.30', { currency: 'USD' }) + expect(scan.totalCents).toBe(330) + expect(items(scan.items)).toEqual([ + ['Milk', 120, 1], + ['Bread', 210, 1], + ]) + }) + + it('keeps a discount that makes the bill add up, and drops a tip that is not in the total', () => { + const scan = parseReceipt( + 'Taco Stand\nTacos 3 @ 3.00 9.00\nHorchata 4.00\nCoupon -2.00\nTip\nGratuity 1.50\nTotal 11.00', + { currency: 'USD' }, + ) + expect(scan.discountCents).toBe(200) + expect(scan.tipCents).toBeNull() + expect(scan.totalCents).toBe(1100) + expect(items(scan.items)).toEqual([ + ['Tacos', 900, 3], + ['Horchata', 400, 1], + ]) + }) + + it('drops tax, tip and discount together when only the items match the total', () => { + const scan = parseReceipt( + 'Shop\nA thing 5.00\nAnother 5.00\nTax 0.80\nService 1.00\nDiscount 0.50\nTotal 10.00', + { currency: 'USD' }, + ) + expect([scan.taxCents, scan.tipCents, scan.discountCents]).toEqual([null, null, null]) + }) + + it('drops tax and tip but keeps a discount that explains the total', () => { + const scan = parseReceipt( + 'Shop\nA thing 5.00\nAnother 5.00\nVAT 1.60\nTips 2.00\nSavings 1.00\nTotal 9.00', + { currency: 'USD' }, + ) + expect([scan.taxCents, scan.tipCents, scan.discountCents]).toEqual([null, null, 100]) + }) + + it('drops a discount that is not in the total', () => { + const scan = parseReceipt('Shop\nA thing 5.00\nSales tax 0.40\nYou saved 1.00\nTotal 5.40', { + currency: 'USD', + }) + expect([scan.taxCents, scan.discountCents]).toEqual([40, null]) + }) + + it('leaves everything as read when nothing explains the total', () => { + const scan = parseReceipt('Shop\nA thing 5.00\nTax 1.00\nTotal 9.99', { currency: 'USD' }) + expect(scan.taxCents).toBe(100) + }) + + it('takes the biggest of several total lines', () => { + const scan = parseReceipt('Bar\nBeer 6.00\nTotal 6.00\nGrand total incl. tip 7.00', { + currency: 'USD', + }) + expect(scan.totalCents).toBe(700) + }) + + it('returns nothing much for text that is not a receipt', () => { + expect(parseReceipt('', { currency: 'USD' })).toEqual({ + merchant: null, + date: null, + currency: null, + totalCents: null, + subtotalCents: null, + taxCents: null, + tipCents: null, + discountCents: null, + items: [], + }) + const scan = parseReceipt('12\n****\nhello there', { currency: 'USD' }) + expect(scan.totalCents).toBeNull() + expect(scan.merchant).toBe('hello there') + }) + + it('caps a runaway item list', () => { + const lines = Array.from({ length: MAX_RECEIPT_ITEMS + 10 }, (_, i) => `Thing ${i} 1.00`) + const scan = parseReceipt(['Big Shop', ...lines].join('\n'), { currency: 'USD' }) + expect(scan.items).toHaveLength(MAX_RECEIPT_ITEMS) + }) + + it('skips header lines that are not a shop name', () => { + const scan = parseReceipt( + 'RECEIPT\nwww.example.com\n42 Elm Road\n12/12/2026\nBlue Bottle\nLatte 5.50', + { + currency: 'USD', + }, + ) + expect(scan.merchant).toBe('Blue Bottle') + }) + + it('uses a currency code over a lone symbol, and the group dollar for a bare $', () => { + expect(parseReceipt('Total CHF 12.50', { currency: 'EUR' }).currency).toBe('CHF') + expect(parseReceipt('Total $12.50', { currency: 'CAD' }).currency).toBe('CAD') + expect(parseReceipt('Total $12.50', { currency: 'EUR' }).currency).toBe('USD') + expect(parseReceipt('Total ₹120.00\nTip ₩100', { currency: 'EUR' }).currency).toBe('INR') + }) + + it('ignores yen-like numbers that carry no symbol', () => { + const scan = parseReceipt('Shop\nTable 12\nRamen ¥980\nTotal ¥980', { currency: 'JPY' }) + expect(items(scan.items)).toEqual([['Ramen', 980, 1]]) + }) +}) + +describe('dates', () => { + const dateOf = (line: string, extra: Partial[1]> = {}) => + parseReceipt(line, { currency: 'EUR', ...extra }).date + + it('reads ISO, numeric and written dates', () => { + expect(dateOf('2026-03-04')).toBe('2026-03-04') + expect(dateOf('03/04/2026')).toBe('2026-04-03') + expect(dateOf('03/04/2026', { dateOrder: 'mdy' })).toBe('2026-03-04') + expect(dateOf('13/04/26', { dateOrder: 'mdy' })).toBe('2026-04-13') + expect(dateOf('03.04.2026', { dateOrder: 'mdy' })).toBe('2026-04-03') + expect(dateOf('Nov 12, 2026')).toBe('2026-11-12') + expect(dateOf('12th November 2026')).toBe('2026-11-12') + expect(dateOf('12. Dezember 2026')).toBe('2026-12-12') + expect(dateOf('1 juin 2026')).toBe('2026-06-01') + expect(dateOf('14 juillet 2026')).toBe('2026-07-14') + expect(dateOf('3. Juli 2026')).toBe('2026-07-03') + expect(dateOf('4 août 2026')).toBe('2026-08-04') + }) + + it('skips impossible, future and very old dates', () => { + expect(dateOf('31/02/2026')).toBeNull() + expect(dateOf('2026-13-01')).toBeNull() + expect(dateOf('12 foo 2026')).toBeNull() + expect(dateOf('20/11/2026', { today: '2026-11-13' })).toBeNull() + expect(dateOf('20/11/2006', { today: '2026-11-13' })).toBeNull() + expect(dateOf('20/11/2026\n01/11/2026', { today: '2026-11-13' })).toBe('2026-11-01') + }) +}) + +describe('parseReceiptAmount', () => { + it.each([ + ['12.50', 'USD', 1250], + ['12,50', 'EUR', 1250], + ['1,234.56', 'USD', 123456], + ['1.234,56', 'EUR', 123456], + ["1'234.50", 'CHF', 123450], + ['12,5', 'EUR', 1250], + ['1,280', 'JPY', 1280], + ['1.234.567', 'EUR', 123456700], + ['12.345', 'KWD', 12345], + ['7', 'USD', 700], + ])('%s in %s is %i', (raw, currency, cents) => { + expect(parseReceiptAmount(raw, currency)).toBe(cents) + }) + + it.each([['1.2345'], ['abc'], ['.50'], ['12.'], ['99999999999999999']])( + '%s is not an amount', + (raw) => { + expect(parseReceiptAmount(raw, 'USD')).toBeNull() + }, + ) +}) + +describe('receiptDateOrder', () => { + it('is month first for US English and dollars, day first elsewhere', () => { + expect(receiptDateOrder('USD')).toBe('mdy') + expect(receiptDateOrder('EUR')).toBe('dmy') + expect(receiptDateOrder('USD', 'en-GB')).toBe('dmy') + expect(receiptDateOrder('EUR', 'en-US')).toBe('mdy') + }) +}) diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts new file mode 100644 index 0000000..169e174 --- /dev/null +++ b/packages/core/src/receipts/parse-receipt.ts @@ -0,0 +1,445 @@ +import { minorUnits } from '../currency' + +/** One priced line read off a receipt, in minor units of the scan's currency. */ +export interface ReceiptItem { + description: string + amountCents: number + /** How many were bought ("2 x Coffee"); the amount is the line total. */ + quantity: number +} + +/** + * What a receipt says, as far as the text lets us tell. Every field may be missing: a scan is a + * suggestion the person confirms, never something saved on its own. + */ +export interface ReceiptScan { + merchant: string | null + /** Calendar day, `YYYY-MM-DD`. */ + date: string | null + /** ISO code when the receipt names its currency (a symbol or a code), else null. */ + currency: string | null + totalCents: number | null + subtotalCents: number | null + /** Tax added on top of the items. Null when the prices already include it (VAT receipts). */ + taxCents: number | null + tipCents: number | null + discountCents: number | null + items: ReceiptItem[] +} + +export interface ParseReceiptOptions { + /** Currency to read amounts in when the receipt does not say (the expense's currency). */ + currency: string + /** How to read an ambiguous 03/04/2026. Defaults to day first. */ + dateOrder?: 'dmy' | 'mdy' + /** Today, `YYYY-MM-DD`: dates after it are misreads and are dropped. */ + today?: string +} + +/** At most this many items are offered; a longer list is almost surely noise. */ +export const MAX_RECEIPT_ITEMS = 60 + +const ITEM_NAME_MAX = 80 +const MERCHANT_MAX = 120 + +/** Day first or month first for a numeric date, from the currency and the viewer's locale. */ +export function receiptDateOrder(currency: string, locale?: string): 'dmy' | 'mdy' { + if (locale) return /^en-US$|^en-PH$|^es-US$/i.test(locale) ? 'mdy' : 'dmy' + return currency.toUpperCase() === 'USD' ? 'mdy' : 'dmy' +} + +// Text helpers --------------------------------------------------------------------------------- + +/** Lowercase without accents, for keyword matching ("Gesamtbetrag", "Montant dû"). */ +function plain(text: string): string { + return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase() +} + +function tidy(text: string): string { + return text.normalize('NFKC').replace(/[\t ]/g, ' ').replace(/\s+/g, ' ').trim() +} + +/** "JOE'S DINER" reads better as "Joe's Diner"; mixed case is left as printed. */ +function titleCase(text: string): string { + const letters = text.replace(/[^\p{L}]/gu, '') + if (letters.length === 0 || letters !== letters.toUpperCase()) return text + return text.toLowerCase().replace(/(^|[\s\-/(])(\p{L})/gu, (_, lead: string, ch: string) => { + return lead + ch.toUpperCase() + }) +} + +// Currency ------------------------------------------------------------------------------------- + +const DOLLARS = new Set(['USD', 'CAD', 'AUD', 'NZD', 'MXN', 'SGD', 'HKD']) +const CODES = /\b(USD|EUR|GBP|CAD|AUD|NZD|CHF|JPY|MXN|BRL|INR|SEK|NOK|DKK|PLN|CZK|SGD|HKD|ZAR)\b/g + +function detectCurrency(text: string, fallback: string): string | null { + const votes = new Map() + const vote = (code: string, n: number) => n && votes.set(code, (votes.get(code) ?? 0) + n) + const count = (re: RegExp) => (text.match(re) ?? []).length + vote('EUR', count(/€/g)) + vote('GBP', count(/£/g)) + vote('JPY', count(/[¥¥円]/g)) + vote('INR', count(/₹/g)) + vote('KRW', count(/₩/g)) + vote('BRL', count(/R\$/g)) + const dollars = count(/(? votes.get(best)!) best = code + return best +} + +// Amounts -------------------------------------------------------------------------------------- + +/** + * A printed amount in minor units: "1,234.50", "1.234,50", "1'234.50", "12,5" (one decimal), + * "1,280" for yen. The last separator is the decimal one unless it is followed by exactly three + * digits in a currency without three decimals. Null when it is not a usable amount. + */ +export function parseReceiptAmount(raw: string, currency: string): number | null { + const digits = minorUnits(currency) + const s = raw.replace(/['\s]/g, '') + if (!/^\d[\d.,]*$/.test(s)) return null + const sep = Math.max(s.lastIndexOf('.'), s.lastIndexOf(',')) + let whole = s + let fraction = '' + if (sep !== -1) { + const after = s.slice(sep + 1) + const mark = s[sep]! + const repeated = s.split(mark).length > 2 + const thousands = + (after.length === 3 && digits !== 3 && !(s.includes('.') && s.includes(','))) || + (repeated && !(s.includes('.') && s.includes(','))) + if (thousands) { + whole = s + } else { + if (after.length === 0 || after.length > digits) return null + whole = s.slice(0, sep) + fraction = after + } + } + whole = whole.replace(/[.,]/g, '') + if (whole === '') return null + const value = Number(whole) * 10 ** digits + Number(fraction.padEnd(digits, '0') || '0') + return Number.isSafeInteger(value) ? value : null +} + +const AMOUNT = String.raw`\d{1,3}(?:[.,']\d{3})+(?:[.,]\d{1,3})?|\d+(?:[.,]\d{1,3})?` +const SYMBOL = String.raw`R\$|[€$£¥¥₹₩]|EUR|USD|GBP|CHF|CAD|AUD|JPY` +const PRICE_AT_END = new RegExp( + String.raw`(?:^|[\s:=])(-)?\s*(${SYMBOL})?\s*(-)?(${AMOUNT})\s*(${SYMBOL}|円)?\s*(-)?$`, + 'u', +) +/** Trailing marks some tills print after a price: VAT class letters, "*", a currency code. */ +const TRAILING_MARKS = /(?:\s+(?:[A-Z]{1,2}\d?|[*#]))+\s*$/u + +interface Priced { + cents: number + negative: boolean + /** The line without the price. */ + rest: string +} + +function trailingPrice(line: string, currency: string): Priced | null { + const trimmed = line.replace(TRAILING_MARKS, '') + const match = PRICE_AT_END.exec(trimmed) + if (!match) return null + const [whole, minusBefore, symbolBefore, minusMiddle, token, symbolAfter, minusAfter] = match + const digits = minorUnits(currency) + const hasDecimals = new RegExp(String.raw`[.,]\d{${digits}}$`).test(token!) + if (digits > 0 && !hasDecimals) return null + if (digits === 0 && !symbolBefore && !symbolAfter && !/[.,']/.test(token!)) return null + const cents = parseReceiptAmount(token!, currency) + if (cents === null) return null + return { + cents, + negative: !!(minusBefore || minusMiddle || minusAfter), + rest: trimmed.slice(0, trimmed.length - whole!.length).trim(), + } +} + +// Line kinds ----------------------------------------------------------------------------------- + +const SUBTOTAL = + /\b(sub\s*-?\s*total|subtot|sous\s*-?\s*total|zwischensumme|zw\.?\s*summe|total\s+ht|net\s+total|total\s+parcial|base\s+imponible|netto(betrag)?)\b/ +const INCLUSIVE = /\b(incl|inkl|incluse?|incluido|included|ttc|inc)\b/ +const TAX = + /\b(tax|taxes|vat|tva|iva|mwst|mw\.?\s?st|ust|gst|hst|pst|qst|impuesto|imposto|steuer|tasse)\b/ +const TIP = + /\b(tip|tips|gratuity|pourboire|trinkgeld|propina|gorjeta|service\s+charge|servicio|service)\b/ +const DISCOUNT = + /\b(discount|remise|rabatt|descuento|desconto|coupon|savings|you\s+saved|reduction|promo)\b/ +const TOTAL = + /\b(total|totale|totaal|gesamt\w*|summe|betrag|amount\s+due|balance\s+due|to\s+pay|a\s+pagar|zu\s+zahlen|montant|net\s+a\s+payer)\b/ +const PAYMENT = + /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|tendered|tend|payment|paid|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|girocard|kartenzahlung|cambio|efectivo|tarjeta|troco|dinheiro|cartao|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b/ +const NOT_AN_ITEM = + /\b(tel|phone|fax|www|http|order|table|server|guest|guests|cashier|kasse|receipt|invoice|facture|rechnung|ticket|recibo|qty|items|articles|artikel|anzahl)\b/ + +type Kind = 'subtotal' | 'tax' | 'tip' | 'discount' | 'total' | 'payment' | 'item' + +function kindOf(key: string): Kind { + if (SUBTOTAL.test(key)) return 'subtotal' + if (TOTAL.test(key) && INCLUSIVE.test(key)) return 'total' + if (TAX.test(key)) return 'tax' + if (TIP.test(key)) return 'tip' + if (DISCOUNT.test(key)) return 'discount' + if (TOTAL.test(key)) return 'total' + if (PAYMENT.test(key)) return 'payment' + return 'item' +} + +// Dates ---------------------------------------------------------------------------------------- + +const MONTHS: Record = { + jan: 1, + ene: 1, + feb: 2, + fev: 2, + mar: 3, + apr: 4, + avr: 4, + abr: 4, + may: 5, + mai: 5, + jun: 6, + jul: 7, + aug: 8, + aou: 8, + ago: 8, + sep: 9, + set: 9, + oct: 10, + okt: 10, + out: 10, + nov: 11, + dec: 12, + dez: 12, + dic: 12, +} + +function monthOf(word: string): number | null { + if (word.startsWith('juin') || word === 'juni') return 6 + if (word.startsWith('juil') || word === 'juli') return 7 + return MONTHS[word.slice(0, 3)] ?? null +} + +function isoDay(year: number, month: number, day: number): string | null { + if (month < 1 || month > 12 || day < 1) return null + const days = new Date(Date.UTC(year, month, 0)).getUTCDate() + if (day > days) return null + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` +} + +const fullYear = (y: string) => (y.length === 2 ? 2000 + Number(y) : Number(y)) + +function datesIn(key: string, order: 'dmy' | 'mdy'): string[] { + const found: string[] = [] + const push = (day: string | null) => day && found.push(day) + for (const m of key.matchAll(/\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b/g)) { + push(isoDay(Number(m[1]), Number(m[2]), Number(m[3]))) + } + for (const m of key.matchAll(/\b(\d{1,2})([-/.])(\d{1,2})\2(\d{4}|\d{2})(?![\d.,])/g)) { + const a = Number(m[1]) + const b = Number(m[3]) + const year = fullYear(m[4]!) + const dayFirst = m[2] === '.' || a > 12 || (order === 'dmy' && b <= 12) + push(dayFirst ? isoDay(year, b, a) : isoDay(year, a, b)) + } + for (const m of key.matchAll( + /\b(\d{1,2})(?:\.|st|nd|rd|th)?\s*(?:de\s+)?([a-z]{3,10})\.?,?\s*(?:de\s+)?(\d{4}|\d{2})\b/g, + )) { + const month = monthOf(m[2]!) + if (month) push(isoDay(fullYear(m[3]!), month, Number(m[1]))) + } + for (const m of key.matchAll(/\b([a-z]{3,10})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/g)) { + const month = monthOf(m[1]!) + if (month) push(isoDay(Number(m[3]), month, Number(m[2]))) + } + return found +} + +function findDate(keys: string[], order: 'dmy' | 'mdy', today?: string): string | null { + const oldest = today ? `${Number(today.slice(0, 4)) - 10}${today.slice(4)}` : null + for (const key of keys) { + for (const day of datesIn(key, order)) { + if (today && (day > today || day < oldest!)) continue + return day + } + } + return null +} + +// Merchant ------------------------------------------------------------------------------------- + +const NOT_A_NAME = + /\b(receipt|invoice|facture|rechnung|ticket|recibo|welcome|bienvenue|willkommen|bienvenido|bem-vindo|tel|phone|fax|www|http|vat|tva|siret|siren|ust|nif|cif|cnpj|order|table|server|cashier|kasse|date|time|copy|customer)\b|@|\.com\b/ + +function findMerchant(lines: string[]): string | null { + for (const line of lines.slice(0, 6)) { + const key = plain(line) + const letters = line.replace(/[^\p{L}]/gu, '').length + const numbers = line.replace(/\D/g, '').length + if (letters < 3 || numbers > letters) continue + if (NOT_A_NAME.test(key) || /^\d+\s+\p{L}/u.test(line)) continue + if (datesIn(key, 'dmy').length > 0) continue + const name = titleCase(line.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}).!']+$/gu, '').trim()) + if (name.length >= 2) return name.slice(0, MERCHANT_MAX).trim() + } + return null +} + +// Items ---------------------------------------------------------------------------------------- + +const QTY_FIRST = /^(\d{1,2})\s*[x×*]\s*/i +const QTY_BARE = /^(\d{1,2})\s+(?=\p{L})/u +const QTY_AT_END = new RegExp( + String.raw`\s*(\d{1,3})\s*[x×@*]\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, + 'iu', +) +const QTY_SUFFIX = /\s+[x×]\s*(\d{1,2})$/i +const UNIT_AT_END = new RegExp(String.raw`\s+@\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, 'u') + +function itemOf(rest: string): { description: string; quantity: number } { + let text = rest.replace(/^\d{4,}\s+/, '') + let quantity = 1 + const atEnd = QTY_AT_END.exec(text) + if (atEnd) { + quantity = Number(atEnd[1]) + text = text.slice(0, atEnd.index) + } else { + text = text.replace(UNIT_AT_END, '') + } + const suffix = QTY_SUFFIX.exec(text) + if (suffix) { + quantity = Number(suffix[1]) + text = text.slice(0, suffix.index) + } + const first = QTY_FIRST.exec(text) ?? QTY_BARE.exec(text) + if (first && Number(first[1]) > 0 && Number(first[1]) <= 20) { + quantity = Number(first[1]) + text = text.slice(first[0].length) + } + const description = titleCase(text.replace(/^[\s.:\-*#]+|[\s.:\-*#]+$/g, '').replace(/\s+/g, ' ')) + return { + description: description.slice(0, ITEM_NAME_MAX).trim(), + quantity: Math.max(quantity, 1), + } +} + +// The parser ----------------------------------------------------------------------------------- + +/** + * Reads OCR text from a receipt into a total, a date, a merchant and line items. Built for the + * printed till receipts people photograph: US (tax added on top, tip line), UK and Europe (VAT + * included, decimal comma, "1,99 A" class letters), and yen without decimals. It guesses; the + * person checks everything in the expense form before saving. + */ +export function parseReceipt(text: string, options: ParseReceiptOptions): ReceiptScan { + const lines = text + .split(/\r?\n/) + .map(tidy) + .filter((l) => l.length > 0) + const keys = lines.map(plain) + const currency = detectCurrency(text, options.currency) + const readIn = currency ?? options.currency + + let total: number | null = null + let subtotal: number | null = null + let tax: number | null = null + let tip: number | null = null + let discount: number | null = null + let largest: number | null = null + let itemsEnd = lines.length + const items: ReceiptItem[] = [] + let pending: string | null = null + + for (const [index, line] of lines.entries()) { + const key = keys[index]! + const price = trailingPrice(line, readIn) + if (!price) { + // A name on its own line, with "2 x 3,20 6,40" under it. + pending = /\p{L}{2}/u.test(line) && !NOT_AN_ITEM.test(key) ? line : null + continue + } + const kind = kindOf(key) + largest = Math.max(largest ?? 0, price.cents) + switch (kind) { + case 'subtotal': + subtotal ??= price.cents + itemsEnd = Math.min(itemsEnd, index) + break + case 'total': + // "Total" then "Total incl. VAT", or a grand total under a total: the biggest wins. + total = Math.max(total ?? 0, price.cents) + itemsEnd = Math.min(itemsEnd, index) + break + case 'tax': + tax = (tax ?? 0) + price.cents + break + case 'tip': + tip = (tip ?? 0) + price.cents + break + case 'discount': + discount = (discount ?? 0) + price.cents + break + case 'payment': + itemsEnd = Math.min(itemsEnd, index) + break + case 'item': { + if (index > itemsEnd || NOT_AN_ITEM.test(key)) break + if (price.negative) { + discount = (discount ?? 0) + price.cents + break + } + let { description, quantity } = itemOf(price.rest) + if (!/\p{L}{2}/u.test(description) && pending) { + description = itemOf(pending).description + } + if (/\p{L}{2}/u.test(description) && price.cents > 0) { + items.push({ description, amountCents: price.cents, quantity }) + } + break + } + } + pending = null + } + + const offered = items.slice(0, MAX_RECEIPT_ITEMS) + const itemsSum = offered.reduce((a, i) => a + i.amountCents, 0) + if (total === null && largest !== null) total = largest + const base = subtotal ?? itemsSum + // Tax, tip and discount are kept only when they make the receipt add up the way the expense + // form adds it (items + tax + tip - discount). VAT already inside the prices is left out. + if (total !== null && base > 0) { + const sums = (t: number | null, p: number | null, d: number | null) => + base + (t ?? 0) + (p ?? 0) - (d ?? 0) === total + if (!sums(tax, tip, discount)) { + if (sums(null, tip, discount)) tax = null + else if (sums(tax, null, discount)) tip = null + else if (sums(tax, tip, null)) discount = null + else if (sums(null, null, discount)) [tax, tip] = [null, null] + else if (sums(null, null, null)) [tax, tip, discount] = [null, null, null] + } + } + + return { + merchant: findMerchant(lines), + // A receipt that names its currency says where it was printed, which beats the viewer's + // own habit for 03/04 (a euro receipt is day first even for someone in the US). + date: findDate( + keys, + currency ? receiptDateOrder(currency) : (options.dateOrder ?? 'dmy'), + options.today, + ), + currency, + totalCents: total, + subtotalCents: subtotal, + taxCents: tax, + tipCents: tip, + discountCents: discount, + items: offered, + } +} From 7811b89c2040291cb150279b49d0393e222d3cfe Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:17:25 -0500 Subject: [PATCH 02/29] receipt parser: plain whitespace class and const reads Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- .../expenses/form/receipt-field.test.tsx | 127 ------------------ .../features/expenses/form/receipt-field.tsx | 122 ----------------- packages/core/src/receipts/parse-receipt.ts | 12 +- 3 files changed, 7 insertions(+), 254 deletions(-) delete mode 100644 apps/web/src/features/expenses/form/receipt-field.test.tsx delete mode 100644 apps/web/src/features/expenses/form/receipt-field.tsx diff --git a/apps/web/src/features/expenses/form/receipt-field.test.tsx b/apps/web/src/features/expenses/form/receipt-field.test.tsx deleted file mode 100644 index a6fb8da..0000000 --- a/apps/web/src/features/expenses/form/receipt-field.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { t } from '@potluck/i18n' -import { Toaster } from '@potluck/ui' -import { fireEvent, screen } from '@testing-library/react' -import { useState } from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { db, resetDb } from '@/test/supabase' -import { renderWithClient, seededClient } from '../test-utils' -import { receiptExtension, receiptType } from './receipt' -import { ReceiptField, type ReceiptValue } from './receipt-field' - -vi.mock('@/lib/supabase', () => import('@/test/supabase')) - -function Harness({ - initial, - onChange, -}: { - initial: ReceiptValue - onChange: (v: ReceiptValue) => void -}) { - const [value, setValue] = useState(initial) - return ( - <> - { - setValue(next) - onChange(next) - }} - /> - - - ) -} - -function renderField(initial: ReceiptValue = { file: null, existingPath: null }) { - const onChange = vi.fn() - renderWithClient(, seededClient()) - return { onChange, input: screen.getByLabelText(t('expenses.fields.receipt')) } -} - -describe('ReceiptField', () => { - beforeEach(() => { - resetDb() - URL.createObjectURL = vi.fn(() => 'blob:receipt') - URL.revokeObjectURL = vi.fn() - }) - - it('takes a photo and shows it, ready to replace or remove', () => { - const { input, onChange } = renderField() - expect(screen.getByRole('button', { name: t('expenses.fields.addReceipt') })).toBeVisible() - const photo = new File(['jpg'], 'dinner.jpg', { type: 'image/jpeg' }) - fireEvent.change(input, { target: { files: [photo] } }) - - expect(onChange).toHaveBeenLastCalledWith({ file: photo, existingPath: null }) - expect(screen.getByText('dinner.jpg')).toBeInTheDocument() - expect(document.querySelector('img')).toHaveAttribute('src', 'blob:receipt') - - fireEvent.click(screen.getByRole('button', { name: t('expenses.fields.removeReceipt') })) - expect(onChange).toHaveBeenLastCalledWith({ file: null, existingPath: null }) - expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:receipt') - }) - - it('rejects other file types and anything over 10 MB', async () => { - const { input, onChange } = renderField() - fireEvent.change(input, { - target: { files: [new File(['x'], 'notes.txt', { type: 'text/plain' })] }, - }) - expect(await screen.findByText(t('expenses.receiptUpload.wrongType'))).toBeInTheDocument() - - const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'scan.pdf', { - type: 'application/pdf', - }) - fireEvent.change(input, { target: { files: [big] } }) - expect(await screen.findByText(t('expenses.receiptUpload.tooBig'))).toBeInTheDocument() - expect(onChange).not.toHaveBeenCalled() - }) - - it('takes an iPhone HEIC photo the browser could not type', () => { - const { input, onChange } = renderField() - const photo = new File(['heic'], 'IMG_0042.HEIC', { type: '' }) - fireEvent.change(input, { target: { files: [photo] } }) - expect(onChange).toHaveBeenLastCalledWith({ file: photo, existingPath: null }) - }) - - it('shows the stored receipt when editing', async () => { - db().respond('storage:receipts.createSignedUrl', { - data: { signedUrl: 'https://files.test/r.png' }, - }) - renderField({ file: null, existingPath: 'g/r.png' }) - expect(screen.getByRole('button', { name: t('expenses.receiptUpload.change') })).toBeVisible() - await vi.waitFor(() => - expect(document.querySelector('img')).toHaveAttribute('src', 'https://files.test/r.png'), - ) - }) - - it('shows a document icon for a stored PDF', () => { - renderField({ file: null, existingPath: 'g/r.PDF' }) - expect(document.querySelector('img')).toBeNull() - }) -}) - -describe('receiptExtension', () => { - it('prefers the type, then the file name', () => { - expect(receiptExtension(new File([], 'a', { type: 'image/jpeg' }))).toBe('jpg') - expect(receiptExtension(new File([], 'scan.tiff', { type: 'image/tiff' }))).toBe('tiff') - }) -}) - -describe('receiptType', () => { - it('keeps a type the bucket accepts', () => { - expect(receiptType(new File([], 'a.png', { type: 'image/png' }))).toBe('image/png') - }) - - it('reads the extension when the browser gave no type', () => { - expect(receiptType(new File([], 'IMG_1.heic', { type: '' }))).toBe('image/heic') - expect(receiptType(new File([], 'IMG_1.HEIF', { type: '' }))).toBe('image/heic') - expect(receiptType(new File([], 'scan.pdf', { type: 'application/octet-stream' }))).toBe( - 'application/pdf', - ) - }) - - it('refuses everything else', () => { - expect(receiptType(new File([], 'notes.txt', { type: 'text/plain' }))).toBeNull() - expect(receiptType(new File([], 'notes.heic', { type: 'text/plain' }))).toBeNull() - expect(receiptType(new File([], 'mystery', { type: '' }))).toBeNull() - }) -}) diff --git a/apps/web/src/features/expenses/form/receipt-field.tsx b/apps/web/src/features/expenses/form/receipt-field.tsx deleted file mode 100644 index 23c53c1..0000000 --- a/apps/web/src/features/expenses/form/receipt-field.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { moneyQueries } from '@potluck/api' -import { t } from '@potluck/i18n' -import { Button, IconButton, toast } from '@potluck/ui' -import { useQuery } from '@tanstack/react-query' -import { Camera, FileText, X } from 'lucide-react' -import { useEffect, useId, useMemo, useRef } from 'react' -import { Help } from '@/features/help/help' -import { supabase } from '@/lib/supabase' -import { RECEIPT_ACCEPT, receiptType } from './receipt' - -export interface ReceiptValue { - /** A new file to upload on save. */ - file: File | null - /** The stored path when editing, null once removed. */ - existingPath: string | null -} - -const MAX_BYTES = 10 * 1024 * 1024 - -function useObjectUrl(file: File | null) { - const url = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]) - useEffect( - () => () => { - if (url) URL.revokeObjectURL(url) - }, - [url], - ) - return url -} - -export function ReceiptField({ - value, - onChange, -}: { - value: ReceiptValue - onChange: (value: ReceiptValue) => void -}) { - const inputRef = useRef(null) - const id = useId() - const localUrl = useObjectUrl(value.file) - const stored = useQuery({ - ...moneyQueries.receiptUrl(supabase, value.existingPath ?? ''), - enabled: !value.file && !!value.existingPath, - }) - const isPdf = value.file - ? receiptType(value.file) === 'application/pdf' - : (value.existingPath ?? '').toLowerCase().endsWith('.pdf') - const src = value.file ? localUrl : value.existingPath ? stored.data : null - const hasReceipt = !!value.file || !!value.existingPath - - const pick = (file: File | undefined) => { - if (!file) return - if (!receiptType(file)) { - toast.error(t('expenses.receiptUpload.wrongType')) - return - } - if (file.size > MAX_BYTES) { - toast.error(t('expenses.receiptUpload.tooBig')) - return - } - onChange({ ...value, file }) - } - - return ( -
-
- - {t('expenses.fields.receipt')} - - -
- { - pick(e.target.files?.[0]) - e.target.value = '' - }} - /> - {hasReceipt ? ( -
- {src && !isPdf ? ( - - ) : ( - - - - )} - - {value.file?.name ?? t('expenses.fields.receipt')} - - - } - onClick={() => onChange({ file: null, existingPath: null })} - /> -
- ) : ( - - )} -
- ) -} diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 169e174..639a649 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -56,7 +56,7 @@ function plain(text: string): string { } function tidy(text: string): string { - return text.normalize('NFKC').replace(/[\t ]/g, ' ').replace(/\s+/g, ' ').trim() + return text.normalize('NFKC').replace(/\s+/g, ' ').trim() } /** "JOE'S DINER" reads better as "Joe's Diner"; mixed case is left as printed. */ @@ -394,10 +394,12 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip discount = (discount ?? 0) + price.cents break } - let { description, quantity } = itemOf(price.rest) - if (!/\p{L}{2}/u.test(description) && pending) { - description = itemOf(pending).description - } + const read = itemOf(price.rest) + const description = + !/\p{L}{2}/u.test(read.description) && pending + ? itemOf(pending).description + : read.description + const quantity = read.quantity if (/\p{L}{2}/u.test(description) && price.cents > 0) { items.push({ description, amountCents: price.cents, quantity }) } From 2ab8ed1676a424dc518dc68277e93bbb481dc0ef Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:17:26 -0500 Subject: [PATCH 03/29] several attachments per expense 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> --- packages/api/src/attachments.test.ts | 71 ++++ packages/api/src/attachments.ts | 73 ++++ packages/api/src/index.ts | 1 + .../20261113173404_expense_attachments.sql | 283 +++++++++++++++ supabase/tests/01_schema.test.sql | 4 +- .../tests/29_expense_attachments.test.sql | 321 ++++++++++++++++++ 6 files changed, 752 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/attachments.test.ts create mode 100644 packages/api/src/attachments.ts create mode 100644 supabase/migrations/20261113173404_expense_attachments.sql create mode 100644 supabase/tests/29_expense_attachments.test.sql diff --git a/packages/api/src/attachments.test.ts b/packages/api/src/attachments.test.ts new file mode 100644 index 0000000..40a1dd2 --- /dev/null +++ b/packages/api/src/attachments.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + attachmentKeys, + attachmentQueries, + fetchExpenseAttachments, + removeReceiptFiles, + setExpenseAttachments, +} from './attachments' +import { moneyKeys } from './money' +import { argsOf, fakeSupabase } from './testing/fake-supabase' + +const row = (position: number, path: string) => ({ + expense_id: 'e1', + position, + path, + content_type: 'image/jpeg', + uploaded_by: 'u1', + created_at: '2026-11-13T10:00:00Z', +}) + +describe('expense attachments', () => { + it("reads one expense's files in order", async () => { + const fake = fakeSupabase({ + tables: { expense_attachments: [row(1, 'g1/b.jpg'), row(0, 'g1/a.jpg')] }, + }) + const files = await fetchExpenseAttachments(fake.client, 'e1') + expect(files.map((f) => f.path)).toEqual(['g1/a.jpg', 'g1/b.jpg']) + const [query] = fake.queries('expense_attachments') + expect(argsOf(query!, 'eq')).toEqual([['expense_id', 'e1']]) + }) + + it('sets the list through set_expense_attachments', async () => { + const fake = fakeSupabase().respond('rpc:set_expense_attachments', { + data: [row(0, 'g1/a.jpg')], + }) + expect(await setExpenseAttachments(fake.client, 'e1', ['g1/a.jpg'])).toHaveLength(1) + expect(fake.rpcArgs('set_expense_attachments')).toEqual([ + { id: 'e1', attachments: ['g1/a.jpg'] }, + ]) + + const failing = fakeSupabase().respond('rpc:set_expense_attachments', { + error: { message: 'too_many_attachments' }, + }) + await expect(setExpenseAttachments(failing.client, 'e1', [])).rejects.toEqual({ + message: 'too_many_attachments', + }) + }) + + it('removes files quietly, and skips the call when there is nothing to remove', async () => { + const fake = fakeSupabase() + await removeReceiptFiles(fake.client, []) + expect(fake.storageCalls('receipts')).toHaveLength(0) + await removeReceiptFiles(fake.client, ['g1/a.jpg']) + expect(fake.storageCalls('receipts')[0]).toMatchObject({ + method: 'remove', + args: [['g1/a.jpg']], + }) + + const offline = fakeSupabase().respond('storage:receipts.remove', () => { + throw new Error('offline') + }) + await expect(removeReceiptFiles(offline.client, ['g1/a.jpg'])).resolves.toBeUndefined() + }) + + it('keeps the list under the expense key', () => { + expect(attachmentKeys.list('e1').slice(0, 4)).toEqual([...moneyKeys.expense('e1')]) + expect(attachmentQueries.list(fakeSupabase().client, 'e1').queryKey).toEqual( + attachmentKeys.list('e1'), + ) + }) +}) diff --git a/packages/api/src/attachments.ts b/packages/api/src/attachments.ts new file mode 100644 index 0000000..1ce0a70 --- /dev/null +++ b/packages/api/src/attachments.ts @@ -0,0 +1,73 @@ +/** + * Expense attachments: receipts, photos and PDFs in the private `receipts` bucket, up to five per + * expense (ADR 21). Platform-neutral: no DOM imports. + */ +import { queryOptions } from '@tanstack/react-query' +import type { Database } from './database.types' +import type { PotluckClient } from './index' +import { moneyKeys } from './money' + +/** One file on an expense, in order; the first is also `expenses.receipt_path`. */ +export type ExpenseAttachment = Pick< + Database['public']['Tables']['expense_attachments']['Row'], + 'expense_id' | 'position' | 'path' | 'content_type' | 'uploaded_by' | 'created_at' +> + +/** How many files one expense holds (the database refuses a sixth). */ +export const MAX_ATTACHMENTS = 5 + +export const attachmentKeys = { + /** Under the expense key, so anything that refreshes the expense refreshes its files. */ + list: (expenseId: string) => [...moneyKeys.expense(expenseId), 'attachments'] as const, +} + +function unwrap(result: { data: T | null; error: unknown }): T { + if (result.error) throw result.error + return result.data as T +} + +export async function fetchExpenseAttachments( + client: PotluckClient, + expenseId: string, +): Promise { + return unwrap( + await client + .from('expense_attachments') + .select('expense_id, position, path, content_type, uploaded_by, created_at') + .eq('expense_id', expenseId) + .order('position'), + ) +} + +/** + * Replaces an expense's attachments with `paths`, in order. The files must already be uploaded + * under the group folder (`uploadReceipt`). + */ +export async function setExpenseAttachments( + client: PotluckClient, + expenseId: string, + paths: string[], +): Promise { + return unwrap(await client.rpc('set_expense_attachments', { id: expenseId, attachments: paths })) +} + +/** + * Deletes files that are no longer on any expense. The bucket lets the uploader or a group admin + * delete; anything else stays put, so this never throws. + */ +export async function removeReceiptFiles(client: PotluckClient, paths: string[]): Promise { + if (paths.length === 0) return + try { + await client.storage.from('receipts').remove(paths) + } catch { + // A leftover file costs storage, not correctness. + } +} + +export const attachmentQueries = { + list: (client: PotluckClient, expenseId: string) => + queryOptions({ + queryKey: attachmentKeys.list(expenseId), + queryFn: () => fetchExpenseAttachments(client, expenseId), + }), +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 508732d..50afe94 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -19,6 +19,7 @@ export const queryKeys = { export * from './groups' export * from './money' +export * from './attachments' export * from './payments' export * from './insights' export * from './plans' diff --git a/supabase/migrations/20261113173404_expense_attachments.sql b/supabase/migrations/20261113173404_expense_attachments.sql new file mode 100644 index 0000000..25a22e1 --- /dev/null +++ b/supabase/migrations/20261113173404_expense_attachments.sql @@ -0,0 +1,283 @@ +-- Several attachments per expense (ADR 21). +-- +-- expense_attachments: up to five receipts, photos or PDFs per expense. The files stay in the +-- private `receipts` bucket under `/`, so the bucket's member policies and the two-step +-- restrictive policy (20261113100301) cover every one of them; nothing about storage changes. +-- Members of the group read the rows; only set_expense_attachments and the trigger below write. +-- +-- expenses.receipt_path stays the first attachment, so older clients keep showing one receipt: +-- * set_expense_attachments writes it along with the list; +-- * an older client that sets or clears receipt_path through create_expense_v2 or +-- update_expense_v2 changes the list the same way: its old file leaves, its new file goes +-- first, the others stay. +-- Every existing receipt becomes the first attachment of its expense. + +create table public.expense_attachments ( + expense_id uuid not null references public.expenses (id) on delete cascade, + position smallint not null check (position >= 0 and position < 5), + group_id uuid not null references public.groups (id) on delete cascade, + path text not null check (char_length(path) <= 300), + content_type text not null check ( + content_type in ('image/png', 'image/jpeg', 'image/webp', 'image/heic', 'application/pdf') + ), + uploaded_by uuid references public.profiles (id) on delete set null, + created_at timestamptz not null default now(), + primary key (expense_id, position), + unique (expense_id, path), + constraint expense_attachments_in_group_folder check (path like group_id::text || '/%') +); + +create index expense_attachments_group_idx on public.expense_attachments (group_id); +create index expense_attachments_uploaded_by_idx on public.expense_attachments (uploaded_by); + +comment on table public.expense_attachments is + 'Receipts, photos and PDFs on an expense, in order (files in the receipts bucket). The first is also expenses.receipt_path.'; + +alter table public.expense_attachments enable row level security; +revoke all on public.expense_attachments from anon, authenticated; +grant select (expense_id, position, group_id, path, content_type, uploaded_by, created_at) + on public.expense_attachments to authenticated; +grant all on public.expense_attachments to service_role; + +create policy "expense_attachments: members read" +on public.expense_attachments for select +to authenticated +using (group_id = any (array(select private.my_group_ids()))); + +-- Helpers ------------------------------------------------------------------------------------- + +-- The type a stored receipt was uploaded as, else a guess from its extension. +create function private.receipt_content_type(file_path text) returns text +language sql stable security definer set search_path = '' as $$ + select coalesce( + ( + select o.metadata ->> 'mimetype' from storage.objects o + where o.bucket_id = 'receipts' and o.name = file_path + and o.metadata ->> 'mimetype' in + ('image/png', 'image/jpeg', 'image/webp', 'image/heic', 'application/pdf') + ), + case lower(substring(file_path from '\.([A-Za-z0-9]+)$')) + when 'pdf' then 'application/pdf' + when 'png' then 'image/png' + when 'webp' then 'image/webp' + when 'heic' then 'image/heic' + when 'heif' then 'image/heic' + else 'image/jpeg' + end + ); +$$; + +-- A new list entry for a file: its type, and who uploaded it (the storage owner, else `who`). +create function private.attachment_item(file_path text, who uuid) returns jsonb +language sql stable security definer set search_path = '' as $$ + select jsonb_build_object( + 'path', file_path, + 'content_type', private.receipt_content_type(file_path), + 'uploaded_by', ( + select p.id from public.profiles p + where p.id = coalesce( + (select private.try_uuid(o.owner_id) from storage.objects o + where o.bucket_id = 'receipts' and o.name = file_path), + who + ) + ), + 'created_at', now() + ); +$$; + +-- An expense's attachments as a jsonb list, in order. +create function private.attachment_list(eid uuid) returns jsonb +language sql stable security definer set search_path = '' as $$ + select coalesce( + jsonb_agg( + jsonb_build_object( + 'path', a.path, 'content_type', a.content_type, + 'uploaded_by', a.uploaded_by, 'created_at', a.created_at + ) + order by a.position + ), + '[]'::jsonb + ) + from public.expense_attachments a + where a.expense_id = eid; +$$; + +-- Replaces an expense's attachments with `list` (entries as attachment_item builds them). +create function private.write_attachments(eid uuid, gid uuid, list jsonb) returns void +language plpgsql security definer set search_path = '' as $$ +begin + delete from public.expense_attachments a where a.expense_id = eid; + insert into public.expense_attachments + (expense_id, position, group_id, path, content_type, uploaded_by, created_at) + select eid, (x.ord - 1)::smallint, gid, x.item ->> 'path', x.item ->> 'content_type', + (x.item ->> 'uploaded_by')::uuid, coalesce((x.item ->> 'created_at')::timestamptz, now()) + from jsonb_array_elements(coalesce(list, '[]'::jsonb)) with ordinality as x (item, ord); +end; +$$; + +revoke execute on function private.receipt_content_type(text) from public; +revoke execute on function private.attachment_item(text, uuid) from public; +revoke execute on function private.attachment_list(uuid) from public; +revoke execute on function private.write_attachments(uuid, uuid, jsonb) from public; + +-- Older clients ---------------------------------------------------------------------------------- + +-- Keeps the list in step with a receipt_path written by an older client. Writes that already +-- agree with the list (set_expense_attachments) pass through untouched. +create function private.sync_receipt_attachment() returns trigger +language plpgsql security definer set search_path = '' as $$ +declare + list jsonb; + kept jsonb; +begin + if tg_op = 'INSERT' then + if new.receipt_path is not null then + perform private.write_attachments(new.id, new.group_id, + jsonb_build_array(private.attachment_item(new.receipt_path, new.created_by))); + end if; + return null; + end if; + + if new.receipt_path is not distinct from old.receipt_path then + return new; + end if; + list := private.attachment_list(new.id); + if new.receipt_path is not distinct from (list -> 0 ->> 'path') then + return new; + end if; + + select coalesce(jsonb_agg(x.item order by x.ord), '[]'::jsonb) into kept + from jsonb_array_elements(list) with ordinality as x (item, ord) + where (x.item ->> 'path') is distinct from old.receipt_path + and (x.item ->> 'path') is distinct from new.receipt_path; + if new.receipt_path is not null then + kept := jsonb_build_array(private.attachment_item(new.receipt_path, auth.uid())) || kept; + end if; + select coalesce(jsonb_agg(x.item order by x.ord), '[]'::jsonb) into kept + from jsonb_array_elements(kept) with ordinality as x (item, ord) + where x.ord <= 5; + + perform private.write_attachments(new.id, new.group_id, kept); + new.receipt_path := kept -> 0 ->> 'path'; + return new; +end; +$$; + +create trigger expenses_receipt_attachment_insert +after insert on public.expenses +for each row execute function private.sync_receipt_attachment(); + +create trigger expenses_receipt_attachment_update +before update of receipt_path on public.expenses +for each row execute function private.sync_receipt_attachment(); + +-- Existing receipts -------------------------------------------------------------------------------- + +insert into public.expense_attachments + (expense_id, position, group_id, path, content_type, uploaded_by, created_at) +select e.id, 0, e.group_id, e.receipt_path, private.receipt_content_type(e.receipt_path), + (select p.id from public.profiles p where p.id = e.created_by), e.created_at +from public.expenses e +where e.receipt_path is not null + and e.receipt_path like e.group_id::text || '/%'; + +-- The RPC ---------------------------------------------------------------------------------------- + +-- Sets an expense's attachments, in order: `attachments` is a list of storage paths (or +-- {path} objects) of files already uploaded under the group's folder. Up to five. Who may edit +-- the expense may change them (a member their own expenses, admins any); they are details, so +-- an expense that names someone who left still takes them. Logs one `expense.updated` with an +-- `attachments` change (count before and after). Removing a file from the list does not delete +-- it from storage; the client does that with the bucket's own rules. +create or replace function public.set_expense_attachments(id uuid, attachments jsonb) +returns setof public.expense_attachments +language plpgsql security definer set search_path = '' as $$ +#variable_conflict use_column +declare + uid uuid := private.require_user(); + eid uuid := set_expense_attachments.id; + my_role public.group_role; + gid uuid; + prev public.expenses; + old_list jsonb; + new_list jsonb := '[]'::jsonb; + entry jsonb; + item jsonb; + file_path text; +begin + if attachments is null or jsonb_typeof(attachments) <> 'array' then + raise exception using message = 'invalid_payload', detail = 'Attachments must be a list', errcode = '22023'; + end if; + + select e.group_id into gid from public.expenses e where e.id = eid; + if gid is null then + raise exception using message = 'expense_not_found', detail = 'Expense not found', errcode = 'PT404'; + end if; + perform 1 from public.groups g where g.id = gid for share; + my_role := private.require_member(gid); + perform private.require_group_open(gid); + + select * into prev from public.expenses e where e.id = eid for update; + if my_role = 'member' and uid is distinct from prev.created_by and uid <> prev.paid_by + and not exists (select 1 from public.expense_payers y where y.expense_id = eid and y.user_id = uid) then + raise exception using message = 'not_allowed', detail = 'You can only edit your own expenses', errcode = '42501'; + end if; + if prev.deleted_at is not null then + raise exception using message = 'expense_deleted', detail = 'Restore this expense before editing it', errcode = '23514'; + end if; + if jsonb_array_length(attachments) > 5 then + raise exception using message = 'too_many_attachments', detail = 'An expense holds up to five attachments', errcode = '22023'; + end if; + + old_list := private.attachment_list(eid); + for entry in select x.v from jsonb_array_elements(attachments) as x (v) loop + file_path := case jsonb_typeof(entry) + when 'string' then entry #>> '{}' + when 'object' then entry ->> 'path' + end; + if file_path is null or file_path not like gid::text || '/%' or file_path like '%..%' + or char_length(file_path) > 300 + or exists (select 1 from jsonb_array_elements(new_list) as n (v) where n.v ->> 'path' = file_path) then + raise exception using message = 'invalid_receipt_path', detail = 'Receipts must be stored under the group folder', errcode = '22023'; + end if; + -- A file already on the expense keeps who added it and when; a new one must be uploaded. + select o.v into item from jsonb_array_elements(old_list) as o (v) where o.v ->> 'path' = file_path; + if item is null then + if not exists ( + select 1 from storage.objects o where o.bucket_id = 'receipts' and o.name = file_path + ) then + raise exception using message = 'attachment_not_found', detail = 'That file has not been uploaded', errcode = 'PT404'; + end if; + item := private.attachment_item(file_path, uid); + end if; + new_list := new_list || jsonb_build_array(item); + end loop; + + if (select coalesce(jsonb_agg(x.v ->> 'path'), '[]'::jsonb) from jsonb_array_elements(new_list) as x (v)) + is distinct from + (select coalesce(jsonb_agg(x.v ->> 'path'), '[]'::jsonb) from jsonb_array_elements(old_list) as x (v)) then + perform private.write_attachments(eid, gid, new_list); + -- The first file is the receipt older clients show. The row is touched even when it stays + -- the same, so realtime tells everyone looking at the expense. This function logs the edit. + perform set_config('potluck.expense_logged', eid::text, true); + update public.expenses e set receipt_path = new_list -> 0 ->> 'path' where e.id = eid; + perform private.log_activity(gid, uid, 'expense.updated', eid, jsonb_build_object( + 'description', prev.description, + 'amount_cents', prev.amount_cents, + 'currency', prev.currency, + 'paid_by', prev.paid_by, + 'is_refund', prev.is_refund, + 'changes', jsonb_build_object('attachments', jsonb_build_object( + 'from', jsonb_array_length(old_list), + 'to', jsonb_array_length(new_list) + )) + )); + end if; + + return query + select a.* from public.expense_attachments a where a.expense_id = eid order by a.position; +end; +$$; + +revoke execute on function public.set_expense_attachments(uuid, jsonb) from public, anon; +grant execute on function public.set_expense_attachments(uuid, jsonb) to authenticated, service_role; diff --git a/supabase/tests/01_schema.test.sql b/supabase/tests/01_schema.test.sql index 40edd44..eb2757f 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -11,11 +11,12 @@ -- Count the assertions and update plan(N); finish() fails when it is off. begin; select plan(126); +select plan(98); select tables_are('public', array[ 'profiles', 'friendships', 'blocks', 'reports', 'groups', 'group_members', 'group_invites', - 'expenses', 'expense_splits', 'expense_payers', 'expense_items', 'recurring_expenses', 'settlements', 'fx_rates', + 'expenses', 'expense_splits', 'expense_payers', 'expense_items', 'expense_attachments', 'recurring_expenses', 'settlements', 'fx_rates', 'payment_methods', 'debt_reminders', 'events', 'availability', 'availability_requests', 'event_rsvps', 'busy_blocks', 'calendar_connections', @@ -76,6 +77,7 @@ select has_function('public', 'make_expense_recurring', array['uuid', 'recurrenc select has_function('public', 'import_expenses', array['uuid', 'jsonb', 'text', 'uuid', 'text'], 'import_expenses(group_id, rows, source, batch_id, fingerprint)'); select has_function('public', 'import_stage', array['uuid', 'uuid', 'jsonb'], 'import_stage(batch_id, group_id, rows)'); select has_function('public', 'export_my_data', array[]::text[], 'export_my_data'); +select has_function('public', 'set_expense_attachments', array['uuid', 'jsonb'], 'set_expense_attachments'); select has_function('public', 'settle_up', array['uuid', 'uuid', 'bigint', 'text', 'text'], 'settle_up'); select has_function('public', 'delete_settlement', array['uuid'], 'delete_settlement'); select has_function('public', 'simplified_debts', array['uuid'], 'simplified_debts'); diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql new file mode 100644 index 0000000..b82f0cc --- /dev/null +++ b/supabase/tests/29_expense_attachments.test.sql @@ -0,0 +1,321 @@ +-- Several attachments per expense (20261113155139_expense_attachments, ADR 21): the files stay in +-- the private receipts bucket under the group folder, so other groups, anon and an unfinished +-- two-step sign in read none of them; set_expense_attachments and the receipt_path mirror for +-- older clients. +begin; +select plan(46); + +select tests.create_user('ava'); +select tests.create_user('ben'); +select tests.create_user('cleo'); +select tests.create_user('outsider'); + +select tests.remember('g', tests.group_with('ava', array['ben'], array['cleo'], 'Dinner club', 'USD')); +select tests.remember('other', tests.group_with('outsider', '{}', '{}', 'Elsewhere', 'USD')); + +create function pg_temp.put(path text) returns void language sql as $$ + insert into storage.objects (bucket_id, name, owner_id, metadata) + values ('receipts', path, auth.uid()::text, + case when path like '%.pdf' then '{"mimetype": "application/pdf"}'::jsonb end) +$$; +create function pg_temp.file(name text) returns text language sql as $$ + select tests.id('g')::text || '/' || name +$$; +create function pg_temp.files(variadic names text[]) returns jsonb language sql as $$ + select jsonb_agg(tests.id('g')::text || '/' || n order by o) + from unnest(names) with ordinality as x (n, o) +$$; +create function pg_temp.paths(ekey text) returns text language sql as $$ + select string_agg(split_part(a.path, '/', 2), ',' order by a.position) + from public.expense_attachments a where a.expense_id = tests.id(ekey) +$$; +grant execute on function pg_temp.put(text) to authenticated, anon; +grant execute on function pg_temp.file(text) to authenticated, anon; +grant execute on function pg_temp.files(text[]) to authenticated, anon; + +-- Files and an expense with one receipt, the way an older client saves it. +select tests.login('ava'); +select pg_temp.put(pg_temp.file('one.jpg')); +select pg_temp.put(pg_temp.file('two.png')); +select pg_temp.put(pg_temp.file('three.pdf')); +select pg_temp.put(pg_temp.file('four.webp')); +select pg_temp.put(pg_temp.file('five.jpg')); +select pg_temp.put(pg_temp.file('six.jpg')); +select tests.remember('e', (select id from public.create_expense_v2(jsonb_build_object( + 'group_id', tests.id('g'), 'description', 'Dinner', 'amount_cents', 9000, + 'paid_by', tests.uid('ava'), 'splits', tests.people('ava', 'ben'), + 'receipt_path', pg_temp.file('one.jpg'))))); +reset role; + +select is(pg_temp.paths('e'), 'one.jpg', 'a receipt saved by an older client is the first attachment'); +select is( + (select uploaded_by from public.expense_attachments where expense_id = tests.id('e')), + tests.uid('ava'), + 'the uploader is recorded' +); + +-- Setting the list ------------------------------------------------------------------------------- + +select tests.login('ava'); +select is( + (select count(*)::int from public.set_expense_attachments(tests.id('e'), + pg_temp.files('three.pdf', 'one.jpg', 'two.png'))), + 3, + 'the payer sets three attachments' +); +reset role; +select is(pg_temp.paths('e'), 'three.pdf,one.jpg,two.png', 'in the order given'); +select is( + (select receipt_path from public.expenses where id = tests.id('e')), + pg_temp.file('three.pdf'), + 'receipt_path follows the first attachment' +); +select is( + (select content_type from public.expense_attachments where expense_id = tests.id('e') and position = 0), + 'application/pdf', + 'the type comes from the upload' +); +select is( + (select content_type from public.expense_attachments where expense_id = tests.id('e') and position = 2), + 'image/png', + 'else from the extension' +); +select is( + (select count(*)::int from public.activity + where subject_id = tests.id('e') and verb = 'expense.updated'), + 1, + 'one edit is logged' +); +select is( + (select data -> 'changes' -> 'attachments' from public.activity + where subject_id = tests.id('e') and verb = 'expense.updated'), + '{"from": 1, "to": 3}'::jsonb, + 'the edit says how many there were and are' +); + +select tests.login('ava'); +select lives_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + pg_temp.files('three.pdf', 'one.jpg', 'two.png')), + 'the same list again' +); +reset role; +select is( + (select count(*)::int from public.activity where subject_id = tests.id('e') and verb = 'expense.updated'), + 1, + 'changes nothing and logs nothing' +); + +-- Paths are plain strings or {path} objects. +select tests.login('ava'); +select is( + (select count(*)::int from public.set_expense_attachments(tests.id('e'), jsonb_build_array( + jsonb_build_object('path', pg_temp.file('one.jpg')), pg_temp.file('four.webp')))), + 2, + 'objects with a path work too' +); +reset role; +select is(pg_temp.paths('e'), 'one.jpg,four.webp', 'files left out are dropped from the list'); + +-- Who may --------------------------------------------------------------------------------------- + +select tests.login('ben'); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), pg_temp.files('two.png')), + '42501', 'not_allowed', 'a member cannot change attachments on someone else''s expense' +); +select tests.login('cleo'); +select lives_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + pg_temp.files('one.jpg', 'four.webp', 'two.png')), + 'an admin can' +); +select tests.login('outsider'); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), pg_temp.files('two.png')), + '42501', 'not_a_member', 'outsiders cannot' +); +select tests.login_anon(); +select throws_ok( + format($$ select public.set_expense_attachments(%L, '[]') $$, tests.id('e')), + '42501', null, 'anon cannot call it' +); + +-- What it refuses ------------------------------------------------------------------------------- + +select tests.login('ava'); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + pg_temp.files('one.jpg', 'two.png', 'three.pdf', 'four.webp', 'five.jpg', 'six.jpg')), + '22023', 'too_many_attachments', 'five at most' +); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + jsonb_build_array(tests.id('other')::text || '/stolen.jpg')), + '22023', 'invalid_receipt_path', 'only files in the group folder' +); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + jsonb_build_array(pg_temp.file('../x.jpg'))), + '22023', 'invalid_receipt_path', 'no climbing out of the folder' +); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + pg_temp.files('one.jpg', 'one.jpg')), + '22023', 'invalid_receipt_path', 'no file twice' +); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + pg_temp.files('never-uploaded.jpg')), + 'PT404', 'attachment_not_found', 'only files that were uploaded' +); +select throws_ok( + format($$ select public.set_expense_attachments(%L, '{"path": "x"}') $$, tests.id('e')), + '22023', 'invalid_payload', 'a list, not an object' +); +select throws_ok( + $$ select public.set_expense_attachments('00000000-0000-4000-8000-000000000000', '[]') $$, + 'PT404', 'expense_not_found', 'an expense that does not exist' +); +select public.delete_expense(tests.id('e')); +select throws_ok( + format($$ select public.set_expense_attachments(%L, '[]') $$, tests.id('e')), + '23514', 'expense_deleted', 'not on a deleted expense' +); +select public.restore_expense(tests.id('e')); +reset role; +update public.groups set archived_at = now() where id = tests.id('g'); +select tests.login('ava'); +select throws_ok( + format($$ select public.set_expense_attachments(%L, '[]') $$, tests.id('e')), + '23514', 'group_archived', 'not in an archived group' +); +reset role; +update public.groups set archived_at = null where id = tests.id('g'); + +-- Older clients --------------------------------------------------------------------------------- + +select is(pg_temp.paths('e'), 'one.jpg,four.webp,two.png', 'before: three files'); +select tests.login('ava'); +select public.update_expense_v2(tests.id('e'), jsonb_build_object('receipt_path', pg_temp.file('five.jpg'))); +reset role; +select is(pg_temp.paths('e'), 'five.jpg,four.webp,two.png', + 'a new receipt from an older client replaces the first file and keeps the rest'); +select tests.login('ava'); +select public.update_expense_v2(tests.id('e'), jsonb_build_object('receipt_path', null)); +reset role; +select is(pg_temp.paths('e'), 'four.webp,two.png', 'removing it drops only that file'); +select is( + (select receipt_path from public.expenses where id = tests.id('e')), + pg_temp.file('four.webp'), + 'and the next file becomes the receipt' +); +select tests.login('ava'); +select public.update_expense_v2(tests.id('e'), jsonb_build_object('description', 'Dinner out')); +reset role; +select is(pg_temp.paths('e'), 'four.webp,two.png', 'other edits leave attachments alone'); +select tests.login('ava'); +select is( + (select count(*)::int from public.set_expense_attachments(tests.id('e'), '[]')), + 0, + 'clearing the list' +); +reset role; +select is((select receipt_path from public.expenses where id = tests.id('e')), null, 'clears the receipt too'); + +-- Reading ----------------------------------------------------------------------------------------- + +select tests.login('ava'); +select public.set_expense_attachments(tests.id('e'), pg_temp.files('one.jpg', 'two.png')); +select tests.login('ben'); +select is((select count(*)::int from public.expense_attachments), 2, 'group members read the list'); +select is( + (select count(*)::int from storage.objects where bucket_id = 'receipts'), + 6, + 'and every file in the group folder' +); +select tests.login('outsider'); +select is((select count(*)::int from public.expense_attachments), 0, 'other groups read no rows'); +select is( + (select count(*)::int from storage.objects where bucket_id = 'receipts' and name like tests.id('g')::text || '/%'), + 0, + 'and none of the files' +); +select throws_ok( + format($$ select pg_temp.put(%L) $$, pg_temp.file('planted.jpg')), + '42501', null, 'and cannot upload into the group folder' +); +select tests.login_anon(); +select throws_ok( + $$ select count(*) from public.expense_attachments $$, + '42501', null, 'anon cannot read the list' +); +select is((select count(*)::int from storage.objects where bucket_id = 'receipts'), 0, 'or the files'); + +-- Writes go through the function only. +select tests.login('ava'); +select throws_ok( + format($$ insert into public.expense_attachments (expense_id, position, group_id, path, content_type) + values (%L, 4, %L, %L, 'image/jpeg') $$, tests.id('e'), tests.id('g'), pg_temp.file('six.jpg')), + '42501', null, 'members cannot insert rows directly' +); +select throws_ok( + format($$ delete from public.expense_attachments where expense_id = %L $$, tests.id('e')), + '42501', null, 'or delete them' +); + +-- Deleting files keeps the bucket's own rules: the uploader or an admin. (The Storage API sets +-- storage.allow_delete_query for its own deletes; plain SQL deletes are refused without it.) +select set_config('storage.allow_delete_query', 'true', true); +select tests.login('ben'); +select pg_temp.put(pg_temp.file('bens.jpg')); +delete from storage.objects where bucket_id = 'receipts' and name = pg_temp.file('one.jpg'); +reset role; +select is( + (select count(*)::int from storage.objects where name = pg_temp.file('one.jpg')), + 1, + 'a member cannot delete someone else''s file' +); +select tests.login('ben'); +delete from storage.objects where bucket_id = 'receipts' and name = pg_temp.file('bens.jpg'); +reset role; +select is( + (select count(*)::int from storage.objects where name = pg_temp.file('bens.jpg')), + 0, + 'but can delete their own' +); + +-- Two-step sign in: a session that owes its code reads no file. +insert into auth.mfa_factors (id, user_id, friendly_name, factor_type, status, created_at, updated_at, secret) +values ('00000000-0000-4000-8000-0000000000f2', tests.uid('ben'), 'phone', 'totp', 'verified', now(), now(), 'secret'); +select tests.login('ben'); +select set_config('request.jwt.claims', + (current_setting('request.jwt.claims')::jsonb || '{"aal": "aal1"}')::text, true); +select is((select count(*)::int from storage.objects where bucket_id = 'receipts'), 0, + 'aal1 with a factor reads no attachment file'); +select set_config('request.jwt.claims', + (current_setting('request.jwt.claims')::jsonb || '{"aal": "aal2"}')::text, true); +select is((select count(*)::int from storage.objects where bucket_id = 'receipts'), 6, + 'aal2 reads them again'); +reset role; + +-- Old receipts became attachments when the table arrived; existing rows match receipt_path. +select is( + (select count(*)::int from public.expenses e + where e.receipt_path is distinct from ( + select a.path from public.expense_attachments a where a.expense_id = e.id and a.position = 0)), + 0, + 'every receipt_path is its expense''s first attachment' +); + +select is( + (select count(*)::int from public.expense_attachments a + where a.path not like a.group_id::text || '/%'), + 0, + 'every attachment lives in its group folder' +); + +select has_index('public', 'expense_attachments', 'expense_attachments_group_idx', 'group_id is indexed'); + +select * from finish(); +rollback; From e19abc50e8d0fc164943f88b1765aa4536489146 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:17:39 -0500 Subject: [PATCH 04/29] strings for receipt scanning and attachments in every language Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- packages/i18n/src/de/help.ts | 1 + packages/i18n/src/de/money.ts | 60 ++++++++++++++++++++++++++++++++++ packages/i18n/src/en/help.ts | 1 + packages/i18n/src/en/money.ts | 59 +++++++++++++++++++++++++++++++++ packages/i18n/src/es/help.ts | 1 + packages/i18n/src/es/money.ts | 60 ++++++++++++++++++++++++++++++++++ packages/i18n/src/fr/help.ts | 1 + packages/i18n/src/fr/money.ts | 61 +++++++++++++++++++++++++++++++++++ packages/i18n/src/pt/help.ts | 1 + packages/i18n/src/pt/money.ts | 59 +++++++++++++++++++++++++++++++++ 10 files changed, 304 insertions(+) diff --git a/packages/i18n/src/de/help.ts b/packages/i18n/src/de/help.ts index 3678169..31f1239 100644 --- a/packages/i18n/src/de/help.ts +++ b/packages/i18n/src/de/help.ts @@ -17,6 +17,7 @@ export const help = { exchangeRates: 'Woher der Kurs kommt', recurring: 'So funktioniert Wiederholen', receipts: 'Wer einen Beleg sehen kann', + receiptScan: 'So funktioniert das Scannen', simplifiedDebts: 'Wer wem zahlt', settleUp: 'Über das Erfassen einer Zahlung', roles: 'Wer was darf', diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index 869c9ad..a0b90f5 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -295,6 +295,8 @@ export const expenses = { recurring_not_found: 'Diese wiederkehrende Ausgabe wurde beendet.', already_recurring: 'Diese Ausgabe wiederholt sich schon.', invalid_next_run: 'Wähle heute oder ein späteres Datum für die nächste.', + too_many_attachments: 'Eine Ausgabe fasst bis zu fünf Dateien.', + attachment_not_found: 'Eine Datei wurde nicht hochgeladen. Füg sie noch mal hinzu.', invalid_parties: 'Wähl zwei verschiedene Personen.', not_a_party: 'Nur Eigentümer und Admins können eine Zahlung zwischen zwei anderen erfassen.', note_too_long: 'Halte die Notiz unter 500 Zeichen.', @@ -389,6 +391,11 @@ export const expenses = { refundOff: 'Keine Erstattung mehr', receiptAdded: 'Beleg hinzugefügt', receiptRemoved: 'Beleg entfernt', + attachmentsAdded_one: '{count} Anhang hinzugefügt', + attachmentsAdded_other: '{count} Anhänge hinzugefügt', + attachmentsRemoved_one: '{count} Anhang entfernt', + attachmentsRemoved_other: '{count} Anhänge entfernt', + attachments: 'Anhänge geändert', plan: 'Mit einem anderen Plan verknüpft', }, }, @@ -400,6 +407,59 @@ export const expenses = { count_other: '{count} Kommentare', deleted: 'Kommentar gelöscht.', }, + attachments: { + label: 'Belege und Fotos', + add: 'Hinzufügen', + addLabel: 'Foto oder PDF hinzufügen', + count: '{count} von {max}', + full: 'Mehr als fünf gehen nicht. Entfern eins, um ein neues hinzuzufügen.', + leftOut: 'Mehr als fünf gehen nicht, der Rest wurde weggelassen.', + remove: '{name} entfernen', + photo: 'Foto {number}', + pdf: 'PDF {number}', + preparing: 'Foto wird vorbereitet', + open: '{name} öffnen', + alt: 'Foto {number} zu {description}', + position: '{number} von {count}', + previous: 'Zurück', + next: 'Weiter', + openOriginal: 'Original öffnen', + openPdf: 'PDF öffnen', + partial: + 'Gespeichert, aber die Dateien wurden nicht angehängt. Bearbeite die Ausgabe, um sie erneut hinzuzufügen.', + }, + scan: { + button: 'Beleg scannen', + title: 'Beleg scannen', + private: 'Wird direkt auf deinem Gerät gelesen. Nichts verlässt es, bevor du speicherst.', + loading: 'Lesegerät wird vorbereitet', + loadingHint: 'Der erste Scan lädt das Lesegerät einmal. Danach klappt es auch offline.', + reading: 'Beleg wird gelesen', + percent: '{percent} %', + stop: 'Stopp', + failed: 'Den konnten wir nicht lesen. Versuch ein schärferes Foto, flach und gut beleuchtet.', + offline: + 'Der erste Scan braucht eine Verbindung, um das Lesegerät zu laden. Versuch es online noch mal.', + nothing: 'Nichts sah nach Preisen aus. Häng das Foto an und füll die Ausgabe von Hand aus.', + reviewTitle: 'Das steht drauf', + reviewHint: + 'Nimm den Haken bei allem raus, was falsch gelesen wurde. Gespeichert wird erst mit der Ausgabe.', + name: 'Name', + date: 'Datum', + total: 'Summe', + currency: 'Währung', + items_one: '{count} Posten', + items_other: '{count} Posten', + itemsHint: 'Nach Posten geteilt, am Anfang mit allen auf jeder Zeile.', + itemsOff: 'Die Posten ergeben {items}, der Beleg sagt {total}.', + rest: 'Alles andere', + quantity: '{count}× {name}', + attach: 'Foto anhängen', + apply: 'Ausgabe ausfüllen', + attachOnly: 'Nur das Foto anhängen', + again: 'Anderes Foto versuchen', + applied: 'Vom Beleg übernommen. Prüf alles und wähl, wer was hatte.', + }, } satisfies Translation export const categories = { diff --git a/packages/i18n/src/en/help.ts b/packages/i18n/src/en/help.ts index ff4def6..11e9f14 100644 --- a/packages/i18n/src/en/help.ts +++ b/packages/i18n/src/en/help.ts @@ -15,6 +15,7 @@ export const help = { exchangeRates: 'Where the rate comes from', recurring: 'How repeating works', receipts: 'Who can see a receipt', + receiptScan: 'How scanning works', simplifiedDebts: 'About who pays whom', settleUp: 'About recording a payment', paymentLinks: 'How paying with an app works', diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index 78d0e14..98ff848 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -298,6 +298,8 @@ export const expenses = { fx_rate_out_of_range: 'That exchange rate is far from the one Potluck has. Check it, then confirm it.', import_too_large: 'One import takes at most 5000 rows. Split the file and import it in parts.', + too_many_attachments: 'An expense holds up to five files.', + attachment_not_found: 'A file did not upload. Try adding it again.', }, /** Several people paid (or, for a refund, got the money). */ payers: { @@ -389,6 +391,11 @@ export const expenses = { refundOff: 'No longer a refund', receiptAdded: 'Receipt added', receiptRemoved: 'Receipt removed', + attachmentsAdded_one: '{count} attachment added', + attachmentsAdded_other: '{count} attachments added', + attachmentsRemoved_one: '{count} attachment removed', + attachmentsRemoved_other: '{count} attachments removed', + attachments: 'Attachments changed', plan: 'Linked to a different plan', }, }, @@ -400,6 +407,58 @@ export const expenses = { count_other: '{count} comments', deleted: 'Comment deleted.', }, + /** Receipts, photos and PDFs on an expense, up to five. */ + attachments: { + label: 'Receipts and photos', + add: 'Add', + addLabel: 'Add a photo or PDF', + count: '{count} of {max}', + full: 'Five is the limit. Remove one to add another.', + leftOut: 'Five is the limit, so the rest were left out.', + remove: 'Remove {name}', + photo: 'Photo {number}', + pdf: 'PDF {number}', + preparing: 'Getting the photo ready', + open: 'Open {name}', + alt: 'Photo {number} for {description}', + position: '{number} of {count}', + previous: 'Previous', + next: 'Next', + openOriginal: 'Open the original', + openPdf: 'Open the PDF', + partial: 'Saved, but the files did not attach. Edit the expense to add them again.', + }, + /** Reading a receipt photo on the device to fill in the form. */ + scan: { + button: 'Scan a receipt', + title: 'Scan a receipt', + private: 'Read right here on your device. Nothing leaves it until you save.', + loading: 'Getting the reader ready', + loadingHint: 'The first scan fetches the reader once. After that it works offline too.', + reading: 'Reading the receipt', + percent: '{percent}%', + stop: 'Stop', + failed: 'That one did not read. Try a sharper photo, flat and well lit.', + offline: 'The first scan needs a connection to fetch the reader. Try again online.', + nothing: 'Nothing on it looked like prices. Attach the photo and fill it in by hand.', + reviewTitle: 'Here is what it says', + reviewHint: 'Untick anything that came out wrong. Nothing saves until you save the expense.', + name: 'Name', + date: 'Date', + total: 'Total', + currency: 'Currency', + items_one: '{count} item', + items_other: '{count} items', + itemsHint: 'Splits by item, with everyone on every line to start.', + itemsOff: 'The items come to {items}; the receipt says {total}.', + rest: 'Everything else', + quantity: '{count}× {name}', + attach: 'Attach the photo', + apply: 'Fill in the expense', + attachOnly: 'Just attach the photo', + again: 'Try another photo', + applied: 'Filled in from the receipt. Check it, then pick who had what.', + }, } as const export const categories = { diff --git a/packages/i18n/src/es/help.ts b/packages/i18n/src/es/help.ts index 8e6d7ad..d95b5f3 100644 --- a/packages/i18n/src/es/help.ts +++ b/packages/i18n/src/es/help.ts @@ -17,6 +17,7 @@ export const help = { exchangeRates: 'De dónde sale el tipo de cambio', recurring: 'Cómo funcionan los gastos que se repiten', receipts: 'Quién puede ver un recibo', + receiptScan: 'Cómo funciona el escaneo', simplifiedDebts: 'Sobre quién paga a quién', settleUp: 'Sobre registrar un pago', roles: 'Quién puede hacer qué', diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index 0ff9bca..d93bc03 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -288,6 +288,8 @@ export const expenses = { recurring_not_found: 'Ese gasto recurrente se detuvo.', already_recurring: 'Este gasto ya se repite.', invalid_next_run: 'Elige hoy o una fecha posterior para el próximo.', + too_many_attachments: 'Un gasto admite hasta cinco archivos.', + attachment_not_found: 'Un archivo no se subió. Prueba a añadirlo otra vez.', invalid_parties: 'Elige dos personas distintas.', not_a_party: 'Solo quien administra puede registrar un pago entre otras dos personas.', note_too_long: 'La nota debe tener menos de 500 caracteres.', @@ -382,6 +384,11 @@ export const expenses = { refundOff: 'Ya no es un reembolso', receiptAdded: 'Recibo añadido', receiptRemoved: 'Recibo quitado', + attachmentsAdded_one: '{count} adjunto añadido', + attachmentsAdded_other: '{count} adjuntos añadidos', + attachmentsRemoved_one: '{count} adjunto quitado', + attachmentsRemoved_other: '{count} adjuntos quitados', + attachments: 'Adjuntos cambiados', plan: 'Vinculado a otro plan', }, }, @@ -393,6 +400,59 @@ export const expenses = { count_other: '{count} comentarios', deleted: 'Comentario eliminado.', }, + attachments: { + label: 'Recibos y fotos', + add: 'Añadir', + addLabel: 'Añadir una foto o un PDF', + count: '{count} de {max}', + full: 'El límite es cinco. Quita uno para añadir otro.', + leftOut: 'El límite es cinco, así que el resto se quedó fuera.', + remove: 'Quitar {name}', + photo: 'Foto {number}', + pdf: 'PDF {number}', + preparing: 'Preparando la foto', + open: 'Abrir {name}', + alt: 'Foto {number} de {description}', + position: '{number} de {count}', + previous: 'Anterior', + next: 'Siguiente', + openOriginal: 'Abrir el original', + openPdf: 'Abrir el PDF', + partial: + 'Guardado, pero los archivos no se adjuntaron. Edita el gasto para añadirlos otra vez.', + }, + scan: { + button: 'Escanear un recibo', + title: 'Escanear un recibo', + private: 'Se lee aquí mismo, en tu dispositivo. Nada sale de él hasta que guardes.', + loading: 'Preparando el lector', + loadingHint: + 'El primer escaneo descarga el lector una vez. Después funciona también sin conexión.', + reading: 'Leyendo el recibo', + percent: '{percent} %', + stop: 'Parar', + failed: 'Ese no se pudo leer. Prueba con una foto más nítida, plana y con buena luz.', + offline: + 'El primer escaneo necesita conexión para descargar el lector. Inténtalo de nuevo con internet.', + nothing: 'Nada parecía un precio. Adjunta la foto y rellénalo a mano.', + reviewTitle: 'Esto es lo que dice', + reviewHint: 'Desmarca lo que haya salido mal. Nada se guarda hasta que guardes el gasto.', + name: 'Nombre', + date: 'Fecha', + total: 'Total', + currency: 'Moneda', + items_one: '{count} artículo', + items_other: '{count} artículos', + itemsHint: 'Se reparte por artículo, con todos en cada línea para empezar.', + itemsOff: 'Los artículos suman {items}; el recibo dice {total}.', + rest: 'Todo lo demás', + quantity: '{count}× {name}', + attach: 'Adjuntar la foto', + apply: 'Rellenar el gasto', + attachOnly: 'Solo adjuntar la foto', + again: 'Probar con otra foto', + applied: 'Rellenado con el recibo. Revísalo y elige quién tomó qué.', + }, } satisfies Translation export const categories = { diff --git a/packages/i18n/src/fr/help.ts b/packages/i18n/src/fr/help.ts index b9fa9e5..e21236a 100644 --- a/packages/i18n/src/fr/help.ts +++ b/packages/i18n/src/fr/help.ts @@ -17,6 +17,7 @@ export const help = { exchangeRates: "D'où vient le taux", recurring: 'Comment marche la répétition', receipts: 'Qui peut voir un reçu', + receiptScan: 'Comment marche le scan', simplifiedDebts: 'À propos de qui paie qui', settleUp: "À propos de l'enregistrement d'un paiement", roles: 'Qui peut faire quoi', diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index da628bc..778bbe1 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -294,6 +294,8 @@ export const expenses = { recurring_not_found: 'Cette dépense récurrente a été arrêtée.', already_recurring: 'Cette dépense se répète déjà.', invalid_next_run: "Choisis aujourd'hui ou une date plus tard pour la prochaine.", + too_many_attachments: "Une dépense contient jusqu'à cinq fichiers.", + attachment_not_found: "Un fichier n'est pas passé. Essaie de le rajouter.", invalid_parties: 'Choisis deux personnes différentes.', not_a_party: 'Seuls les propriétaires et admins peuvent enregistrer un paiement entre deux autres personnes.', @@ -389,6 +391,11 @@ export const expenses = { refundOff: "Ce n'est plus un remboursement", receiptAdded: 'Reçu ajouté', receiptRemoved: 'Reçu retiré', + attachmentsAdded_one: '{count} pièce jointe ajoutée', + attachmentsAdded_other: '{count} pièces jointes ajoutées', + attachmentsRemoved_one: '{count} pièce jointe retirée', + attachmentsRemoved_other: '{count} pièces jointes retirées', + attachments: 'Pièces jointes modifiées', plan: 'Liée à une autre sortie', }, }, @@ -400,6 +407,60 @@ export const expenses = { count_other: '{count} commentaires', deleted: 'Commentaire supprimé.', }, + attachments: { + label: 'Reçus et photos', + add: 'Ajouter', + addLabel: 'Ajouter une photo ou un PDF', + count: '{count} sur {max}', + full: "C'est cinq au maximum. Retire un fichier pour en ajouter un autre.", + leftOut: "C'est cinq au maximum, le reste a été laissé de côté.", + remove: 'Retirer {name}', + photo: 'Photo {number}', + pdf: 'PDF {number}', + preparing: 'Préparation de la photo', + open: 'Ouvrir {name}', + alt: 'Photo {number} pour {description}', + position: '{number} sur {count}', + previous: 'Précédent', + next: 'Suivant', + openOriginal: "Ouvrir l'original", + openPdf: 'Ouvrir le PDF', + partial: + "C'est enregistré, mais les fichiers ne sont pas passés. Modifie la dépense pour les rajouter.", + }, + scan: { + button: 'Scanner un reçu', + title: 'Scanner un reçu', + private: "Lu ici même, sur ton appareil. Rien n'en sort avant que tu enregistres.", + loading: 'Préparation du lecteur', + loadingHint: + 'Le premier scan télécharge le lecteur une fois. Ensuite, il marche aussi hors ligne.', + reading: 'Lecture du reçu', + percent: '{percent} %', + stop: 'Arrêter', + failed: "Celui-ci n'a pas pu être lu. Essaie une photo plus nette, à plat et bien éclairée.", + offline: + 'Le premier scan a besoin de connexion pour télécharger le lecteur. Réessaie en ligne.', + nothing: 'Rien ne ressemblait à un prix. Joins la photo et remplis la dépense à la main.', + reviewTitle: 'Voilà ce qui est écrit', + reviewHint: + "Décoche ce qui est mal lu. Rien n'est enregistré avant que tu enregistres la dépense.", + name: 'Nom', + date: 'Date', + total: 'Total', + currency: 'Devise', + items_one: '{count} article', + items_other: '{count} articles', + itemsHint: 'Partagé par article, avec tout le monde sur chaque ligne pour commencer.', + itemsOff: 'Les articles font {items} ; le reçu dit {total}.', + rest: 'Tout le reste', + quantity: '{count}× {name}', + attach: 'Joindre la photo', + apply: 'Remplir la dépense', + attachOnly: 'Joindre seulement la photo', + again: 'Essayer une autre photo', + applied: 'Rempli depuis le reçu. Vérifie, puis choisis qui a pris quoi.', + }, } satisfies Translation export const categories = { diff --git a/packages/i18n/src/pt/help.ts b/packages/i18n/src/pt/help.ts index 918e965..9773f59 100644 --- a/packages/i18n/src/pt/help.ts +++ b/packages/i18n/src/pt/help.ts @@ -17,6 +17,7 @@ export const help = { exchangeRates: 'De onde vem a taxa', recurring: 'Como funciona a repetição', receipts: 'Quem pode ver um recibo', + receiptScan: 'Como funciona o escaneamento', simplifiedDebts: 'Sobre quem paga a quem', settleUp: 'Sobre registrar um pagamento', roles: 'Quem pode fazer o quê', diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index 95ca1d4..f1c5ec6 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -288,6 +288,8 @@ export const expenses = { recurring_not_found: 'Essa despesa recorrente foi encerrada.', already_recurring: 'Esta despesa já se repete.', invalid_next_run: 'Escolha hoje ou uma data posterior para a próxima.', + too_many_attachments: 'Uma despesa guarda até cinco arquivos.', + attachment_not_found: 'Um arquivo não foi enviado. Tente adicioná-lo de novo.', invalid_parties: 'Escolha duas pessoas diferentes.', not_a_party: 'Só donos e admins podem registrar um pagamento entre duas outras pessoas.', note_too_long: 'Mantenha a nota com menos de 500 caracteres.', @@ -382,6 +384,11 @@ export const expenses = { refundOff: 'Não é mais um reembolso', receiptAdded: 'Recibo adicionado', receiptRemoved: 'Recibo removido', + attachmentsAdded_one: '{count} anexo adicionado', + attachmentsAdded_other: '{count} anexos adicionados', + attachmentsRemoved_one: '{count} anexo removido', + attachmentsRemoved_other: '{count} anexos removidos', + attachments: 'Anexos alterados', plan: 'Ligada a outro plano', }, }, @@ -393,6 +400,58 @@ export const expenses = { count_other: '{count} comentários', deleted: 'Comentário excluído.', }, + attachments: { + label: 'Recibos e fotos', + add: 'Adicionar', + addLabel: 'Adicionar uma foto ou um PDF', + count: '{count} de {max}', + full: 'O limite é cinco. Remova um para adicionar outro.', + leftOut: 'O limite é cinco, então o resto ficou de fora.', + remove: 'Remover {name}', + photo: 'Foto {number}', + pdf: 'PDF {number}', + preparing: 'Preparando a foto', + open: 'Abrir {name}', + alt: 'Foto {number} de {description}', + position: '{number} de {count}', + previous: 'Anterior', + next: 'Próximo', + openOriginal: 'Abrir o original', + openPdf: 'Abrir o PDF', + partial: + 'Salvo, mas os arquivos não foram anexados. Edite a despesa para adicioná-los de novo.', + }, + scan: { + button: 'Escanear um recibo', + title: 'Escanear um recibo', + private: 'Lido aqui mesmo, no seu aparelho. Nada sai dele até você salvar.', + loading: 'Preparando o leitor', + loadingHint: 'O primeiro escaneamento baixa o leitor uma vez. Depois funciona offline também.', + reading: 'Lendo o recibo', + percent: '{percent}%', + stop: 'Parar', + failed: 'Esse não deu para ler. Tente uma foto mais nítida, reta e bem iluminada.', + offline: + 'O primeiro escaneamento precisa de conexão para baixar o leitor. Tente de novo online.', + nothing: 'Nada parecia um preço. Anexe a foto e preencha à mão.', + reviewTitle: 'Isto é o que diz', + reviewHint: 'Desmarque o que saiu errado. Nada é salvo até você salvar a despesa.', + name: 'Nome', + date: 'Data', + total: 'Total', + currency: 'Moeda', + items_one: '{count} item', + items_other: '{count} itens', + itemsHint: 'Dividido por item, com todo mundo em cada linha para começar.', + itemsOff: 'Os itens somam {items}; o recibo diz {total}.', + rest: 'Todo o resto', + quantity: '{count}× {name}', + attach: 'Anexar a foto', + apply: 'Preencher a despesa', + attachOnly: 'Só anexar a foto', + again: 'Tentar outra foto', + applied: 'Preenchido com o recibo. Confira e escolha quem pegou o quê.', + }, } satisfies Translation export const categories = { From 8d150d97bcbb1c27d0e2b8d40b06f5596d36eca3 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:17:39 -0500 Subject: [PATCH 05/29] scan a receipt on the device and attach up to five files "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/ 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> --- apps/web/ocr-assets.ts | 66 +++ apps/web/package.json | 3 + .../features/expenses/expense-detail.test.tsx | 87 +++- .../src/features/expenses/expense-detail.tsx | 49 +- .../form/expense-form-receipts.test.tsx | 196 ++++++++ .../expenses/form/expense-form.test.tsx | 1 - .../features/expenses/form/expense-form.tsx | 110 +++-- .../web/src/features/expenses/history.test.ts | 9 + apps/web/src/features/expenses/history.ts | 13 + .../expenses/receipts/attachment-gallery.tsx | 228 +++++++++ .../expenses/receipts/attachments-field.tsx | 179 +++++++ .../receipts/attachments-state.test.ts | 104 ++++ .../expenses/receipts/attachments-state.ts | 96 ++++ .../src/features/expenses/receipts/image.ts | 51 ++ .../web/src/features/expenses/receipts/ocr.ts | 63 +++ .../expenses/receipts/receipts.test.tsx | 221 +++++++++ .../expenses/receipts/scan-fill.test.ts | 137 ++++++ .../features/expenses/receipts/scan-fill.ts | 100 ++++ .../expenses/receipts/scan-receipt.tsx | 456 ++++++++++++++++++ apps/web/src/features/help/topics.ts | 1 + apps/web/src/vite-env.d.ts | 3 + apps/web/tsconfig.json | 1 + apps/web/vite.config.ts | 16 +- packages/ui/src/sheet.tsx | 15 +- pnpm-lock.yaml | 103 ++++ 25 files changed, 2219 insertions(+), 89 deletions(-) create mode 100644 apps/web/ocr-assets.ts create mode 100644 apps/web/src/features/expenses/form/expense-form-receipts.test.tsx create mode 100644 apps/web/src/features/expenses/receipts/attachment-gallery.tsx create mode 100644 apps/web/src/features/expenses/receipts/attachments-field.tsx create mode 100644 apps/web/src/features/expenses/receipts/attachments-state.test.ts create mode 100644 apps/web/src/features/expenses/receipts/attachments-state.ts create mode 100644 apps/web/src/features/expenses/receipts/image.ts create mode 100644 apps/web/src/features/expenses/receipts/ocr.ts create mode 100644 apps/web/src/features/expenses/receipts/receipts.test.tsx create mode 100644 apps/web/src/features/expenses/receipts/scan-fill.test.ts create mode 100644 apps/web/src/features/expenses/receipts/scan-fill.ts create mode 100644 apps/web/src/features/expenses/receipts/scan-receipt.tsx diff --git a/apps/web/ocr-assets.ts b/apps/web/ocr-assets.ts new file mode 100644 index 0000000..d467cc0 --- /dev/null +++ b/apps/web/ocr-assets.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import type { Plugin } from 'vite' + +const require = createRequire(import.meta.url) +const packageDir = (name: string) => dirname(require.resolve(`${name}/package.json`)) +const version = (require('tesseract.js/package.json') as { version: string }).version + +/** + * Where the receipt reader lives on our own origin. Versioned, so a service worker cache of an + * older reader is never mixed with a newer one. + */ +export const OCR_PATH = `/ocr/${version}` + +/** + * The receipt reader's files, served from our origin instead of a CDN (ADR 21): the tesseract.js + * worker, its WebAssembly core (LSTM builds only, for plain, SIMD and relaxed SIMD devices; each + * device fetches one) and the English model. Nothing here is in the app bundle or the precache: + * the first scan downloads them and the service worker keeps them for offline use. + */ +function files(): Record { + const core = packageDir('tesseract.js-core') + const out: Record = { + 'worker.min.js': join(packageDir('tesseract.js'), 'dist', 'worker.min.js'), + 'lang/eng.traineddata.gz': join( + packageDir('@tesseract.js-data/eng'), + '4.0.0_best_int', + 'eng.traineddata.gz', + ), + } + for (const build of ['lstm', 'simd-lstm', 'relaxedsimd-lstm']) { + out[`core/tesseract-core-${build}.wasm.js`] = join(core, `tesseract-core-${build}.wasm.js`) + } + return out +} + +export function ocrAssets(): Plugin { + return { + name: 'potluck:ocr-assets', + config: () => ({ define: { __OCR_PATH__: JSON.stringify(OCR_PATH) } }), + configureServer(server) { + const served = files() + server.middlewares.use((req, res, next) => { + const url = req.url?.split('?')[0] ?? '' + if (!url.startsWith(`${OCR_PATH}/`)) return next() + const source = served[url.slice(OCR_PATH.length + 1)] + if (!source) return next() + res.setHeader( + 'Content-Type', + url.endsWith('.js') ? 'text/javascript' : 'application/octet-stream', + ) + res.end(readFileSync(source)) + }) + }, + generateBundle() { + for (const [name, source] of Object.entries(files())) { + this.emitFile({ + type: 'asset', + fileName: `${OCR_PATH.slice(1)}/${name}`, + source: readFileSync(source), + }) + } + }, + } +} diff --git a/apps/web/package.json b/apps/web/package.json index d303117..6c09a60 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "^1.170.46", + "@tesseract.js-data/eng": "1.0.0", "clsx": "catalog:", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", @@ -33,6 +34,8 @@ "react-hook-form": "^7.91.0", "sonner": "^2.0.8", "tailwind-merge": "catalog:", + "tesseract.js": "7.0.0", + "tesseract.js-core": "7.0.0", "uqr": "0.1.2", "workbox-window": "^7.4.1", "zod": "catalog:" diff --git a/apps/web/src/features/expenses/expense-detail.test.tsx b/apps/web/src/features/expenses/expense-detail.test.tsx index 7e61936..4481d83 100644 --- a/apps/web/src/features/expenses/expense-detail.test.tsx +++ b/apps/web/src/features/expenses/expense-detail.test.tsx @@ -347,24 +347,87 @@ describe('ExpenseDetail', () => { expect(await screen.findByText(t('errors.generic'))).toBeInTheDocument() }) - it('links the receipt, as an image or a PDF', async () => { - db().respond('storage:receipts.createSignedUrl', { - data: { signedUrl: 'https://files.test/r.jpg' }, + it('shows every attachment, opens photos full screen and links PDFs', async () => { + const file = (position: number, path: string, content_type: string) => ({ + expense_id: ID, + position, + path: `${GROUP_ID}/${path}`, + content_type, + uploaded_by: ME, + created_at: '2026-11-05T00:00:00Z', + }) + resetDb({ + tables: { + expense_attachments: [ + file(0, 'a.jpg', 'image/jpeg'), + file(1, 'b.pdf', 'application/pdf'), + file(2, 'c.png', 'image/png'), + ], + }, + }) + db().respond('storage:receipts.createSignedUrl', (call) => ({ + data: { signedUrl: `https://files.test/${String((call as { args: unknown[] }).args[0])}` }, + })) + renderDetail({ receipt_path: `${GROUP_ID}/a.jpg` }) + + expect( + await screen.findByRole('heading', { name: t('expenses.attachments.label') }), + ).toBeInTheDocument() + expect( + await screen.findByRole('link', { name: t('expenses.receiptUpload.pdf') }), + ).toHaveAttribute('href', `https://files.test/${GROUP_ID}/b.pdf`) + + const photo = t('expenses.attachments.photo', { number: 1 }) + fireEvent.click( + await screen.findByRole('button', { name: t('expenses.attachments.open', { name: photo }) }), + ) + const viewer = await screen.findByRole('dialog', { + name: t('expenses.attachments.position', { number: 1, count: 3 }), + }) + expect( + within(viewer).getByRole('link', { name: t('expenses.attachments.openOriginal') }), + ).toHaveAttribute('href', `https://files.test/${GROUP_ID}/a.jpg`) + fireEvent.click( + within(viewer).getByRole('button', { name: t('expenses.attachments.previous') }), + ) + expect( + await screen.findByRole('dialog', { + name: t('expenses.attachments.position', { number: 3, count: 3 }), + }), + ).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect( + await screen.findByRole('dialog', { + name: t('expenses.attachments.position', { number: 1, count: 3 }), + }), + ).toBeInTheDocument() + }) + + it('calls a single file the receipt, and shows nothing without one', async () => { + resetDb({ + tables: { + expense_attachments: [ + { + expense_id: ID, + position: 0, + path: `${GROUP_ID}/r.jpg`, + content_type: 'image/jpeg', + uploaded_by: ME, + created_at: '2026-11-05T00:00:00Z', + }, + ], + }, }) const first = renderDetail({ receipt_path: `${GROUP_ID}/r.jpg` }) expect( - await screen.findByRole('link', { name: t('expenses.receiptUpload.open') }), - ).toHaveAttribute('href', 'https://files.test/r.jpg') + await screen.findByRole('heading', { name: t('expenses.detail.receipt') }), + ).toBeInTheDocument() first.unmount() resetDb() - db().respond('storage:receipts.createSignedUrl', { - data: { signedUrl: 'https://files.test/r.pdf' }, - }) - renderDetail({ receipt_path: `${GROUP_ID}/r.PDF` }) - expect( - await screen.findByRole('link', { name: t('expenses.receiptUpload.pdf') }), - ).toHaveAttribute('href', 'https://files.test/r.pdf') + renderDetail() + await screen.findByRole('heading', { level: 1 }) + expect(screen.queryByRole('heading', { name: t('expenses.detail.receipt') })).toBeNull() }) it('says when the expense cannot be found', async () => { diff --git a/apps/web/src/features/expenses/expense-detail.tsx b/apps/web/src/features/expenses/expense-detail.tsx index 286920e..96802cc 100644 --- a/apps/web/src/features/expenses/expense-detail.tsx +++ b/apps/web/src/features/expenses/expense-detail.tsx @@ -13,7 +13,7 @@ import { } from '@potluck/ui' import { useQuery, useQueryClient } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' -import { ArrowRightLeft, ChevronLeft, FileText, PencilLine, Repeat, Trash2 } from 'lucide-react' +import { ArrowRightLeft, ChevronLeft, PencilLine, Repeat, Trash2 } from 'lucide-react' import { useState } from 'react' import { useUserId } from '@/lib/auth' import { supabase } from '@/lib/supabase' @@ -22,6 +22,7 @@ import { ExpenseHistory, ItemsCard, PayersLine, SplitCard } from './expense-deta import { CategoryTile } from './expense-row' import { categoryLabel, groupTitle } from './format' import { Money } from './money' +import { AttachmentGallery } from './receipts/attachment-gallery' import { invalidateMoney, moneyErrorMessage, @@ -49,10 +50,6 @@ export function ExpenseDetail({ expenseId }: { expenseId: string }) { const groupId = expense.data?.group_id const group = useMoneyGroup(groupId ?? '') useGroupMoneyRealtime(groupId) - const receipt = useQuery({ - ...moneyQueries.receiptUrl(supabase, expense.data?.receipt_path ?? ''), - enabled: !!expense.data?.receipt_path, - }) if (expense.isError) { return ( @@ -95,7 +92,6 @@ export function ExpenseDetail({ expenseId }: { expenseId: string }) { : false const groupCurrency = group.data?.currency ?? e.currency const foreign = e.currency !== groupCurrency - const isPdf = e.receipt_path?.toLowerCase().endsWith('.pdf') const onDelete = async () => { const ok = await deleteExpense(e) @@ -237,42 +233,11 @@ export function ExpenseDetail({ expenseId }: { expenseId: string }) {
- {e.receipt_path && ( -
-

- {t('expenses.detail.receipt')} -

- {receipt.data ? ( - isPdf ? ( - - - {t('expenses.receiptUpload.pdf')} - - ) : ( - - {t('expenses.receiptUpload.alt', - - ) - ) : ( - - )} -
- )} + {e.notes && (

diff --git a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx new file mode 100644 index 0000000..82e4b65 --- /dev/null +++ b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx @@ -0,0 +1,196 @@ +import type * as Api from '@potluck/api' +import { + attachmentKeys, + createExpense, + removeReceiptFiles, + setExpenseAttachments, + updateExpense, + uploadReceipt, +} from '@potluck/api' +import { t } from '@potluck/i18n' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Ocr from '../receipts/ocr' +import { BEN, CLEO, GROUP_ID, ME, expense, renderWithClient, seededClient } from '../test-utils' +import { ExpenseForm } from './expense-form' + +vi.mock('@/lib/supabase', () => import('../fake-supabase')) +vi.mock('@/lib/auth', () => ({ useUserId: () => 'aaaaaaaa-0000-4000-8000-000000000001' })) +vi.mock('@potluck/api', async (importOriginal) => { + let n = 0 + return { + ...(await importOriginal()), + createExpense: vi.fn(async () => ({ id: 'new-expense' })), + updateExpense: vi.fn(async (_c: unknown, id: string) => ({ id })), + uploadReceipt: vi.fn(async (_c: unknown, groupId: string) => `${groupId}/up-${(n += 1)}.jpg`), + setExpenseAttachments: vi.fn(async () => []), + removeReceiptFiles: vi.fn(async () => undefined), + } +}) +vi.mock('../receipts/ocr', async (original) => ({ + ...(await original()), + readReceiptText: vi.fn( + async () => `TAQUERIA SOL +11/08/2026 +Tacos al pastor 12.00 +Horchata 4.00 +TOTAL 16.00`, + ), +})) + +const photo = (name = 'p.jpg') => new File(['jpg'], name, { type: 'image/jpeg' }) +const addFiles = async (files: File[]) => { + const input = screen.getByLabelText(t('expenses.attachments.label'), { selector: 'input' }) + await act(async () => { + fireEvent.change(input, { target: { files } }) + }) +} + +beforeEach(() => { + vi.clearAllMocks() + URL.createObjectURL = vi.fn(() => 'blob:photo') + URL.revokeObjectURL = vi.fn() +}) + +describe('expense form files', () => { + it('uploads every file, sends the first as the receipt and then sets the list', async () => { + const onDone = vi.fn() + renderWithClient( + {}} />, + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) + await addFiles([photo('a.jpg'), photo('b.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) + expect(vi.mocked(createExpense).mock.calls[0]![1].receipt_path).toBe(`${GROUP_ID}/up-1.jpg`) + expect(vi.mocked(setExpenseAttachments)).toHaveBeenCalledWith( + expect.anything(), + 'new-expense', + [`${GROUP_ID}/up-1.jpg`, `${GROUP_ID}/up-2.jpg`], + ) + }) + + it('saves one file with the expense alone, and none without a call', async () => { + const onDone = vi.fn() + renderWithClient( + {}} />, + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) + await addFiles([photo()]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(onDone).toHaveBeenCalled()) + expect(vi.mocked(createExpense).mock.calls[0]![1].receipt_path).toMatch(/up-\d+\.jpg$/) + expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() + }) + + it('removes a stored file on edit without resending the expense receipt', async () => { + const saved = expense({ id: 'e1', receipt_path: `${GROUP_ID}/a.jpg` }) + const stored = ['a.jpg', 'b.pdf'].map((name, position) => ({ + expense_id: 'e1', + position, + path: `${GROUP_ID}/${name}`, + content_type: name.endsWith('pdf') ? 'application/pdf' : 'image/jpeg', + uploaded_by: ME, + created_at: '2026-11-01T00:00:00Z', + })) + const onDone = vi.fn() + renderWithClient( + {}} />, + seededClient((c) => c.setQueryData(attachmentKeys.list('e1'), stored)), + ) + const first = t('expenses.attachments.photo', { number: 1 }) + fireEvent.click( + await screen.findByRole('button', { + name: t('expenses.attachments.remove', { name: first }), + }), + ) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + + await waitFor(() => expect(onDone).toHaveBeenCalledWith('e1')) + expect(vi.mocked(updateExpense).mock.calls[0]![2]).not.toHaveProperty('receipt_path') + expect(vi.mocked(setExpenseAttachments)).toHaveBeenCalledWith(expect.anything(), 'e1', [ + `${GROUP_ID}/b.pdf`, + ]) + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), [ + `${GROUP_ID}/a.jpg`, + ]) + }) + + it('leaves files alone on an edit that does not touch them', async () => { + const saved = expense({ id: 'e2' }) + const onDone = vi.fn() + renderWithClient( + {}} />, + seededClient((c) => c.setQueryData(attachmentKeys.list('e2'), [])), + ) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Renamed' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('e2')) + expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() + }) + + it('tells the person when the expense saved but its files did not', async () => { + vi.mocked(setExpenseAttachments).mockRejectedValueOnce({ message: 'attachment_not_found' }) + const onDone = vi.fn() + renderWithClient( + {}} />, + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) + await addFiles([photo('a.jpg'), photo('b.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + }) + + it('fills the form from a scanned receipt, split by item, and saves only when asked', async () => { + const onDone = vi.fn() + renderWithClient( + {}} />, + ) + const input = document.querySelector('input[type=file][accept="image/*"]') as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { files: [photo('scan.jpg')] } }) + }) + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + + expect(vi.mocked(createExpense)).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: 'What was it?' })).toHaveValue('Taqueria Sol') + expect(screen.getByDisplayValue('2026-11-08')).toBeInTheDocument() + expect( + screen + .getAllByRole('textbox', { name: t('expenses.itemized.itemName') }) + .map((i) => (i as HTMLInputElement).value), + ).toEqual(['Tacos al pastor', 'Horchata']) + expect( + screen.getByRole('img', { name: t('expenses.attachments.photo', { number: 1 }) }), + ).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(onDone).toHaveBeenCalled()) + const payload = vi.mocked(createExpense).mock.calls[0]![1] + expect(payload).toMatchObject({ + description: 'Taqueria Sol', + amount_cents: 1600, + split_type: 'itemized', + spent_at: '2026-11-08', + }) + expect(payload.items).toEqual([ + { description: 'Tacos al pastor', amount_cents: 1200, user_ids: [ME, BEN, CLEO] }, + { description: 'Horchata', amount_cents: 400, user_ids: [ME, BEN, CLEO] }, + ]) + }) +}) diff --git a/apps/web/src/features/expenses/form/expense-form.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 03d99e7..551dd6a 100644 --- a/apps/web/src/features/expenses/form/expense-form.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form.test.tsx @@ -299,7 +299,6 @@ describe('ExpenseForm', () => { category: 'lodging', notes: null, spent_at: saved.spent_at, - receipt_path: null, }) }) diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index a2715ac..7508a7a 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -1,13 +1,14 @@ import { avatarSrc } from '@/lib/queries' import { + attachmentQueries, categoryHistoryQueryOptions, createExpense, + MAX_ATTACHMENTS, moneyKeys, moneyQueries, setDefaultSplit, updateExpense, updateRecurring, - uploadReceipt, type ExpensePayload, type ExpenseWithSplits, type MoneyGroup, @@ -77,8 +78,16 @@ import { type PayersDraft, } from './payers-state' import { QuickSplit } from './quick-split' -import { receiptExtension, receiptType } from './receipt' -import { ReceiptField, type ReceiptValue } from './receipt-field' +import { AttachmentsField } from '../receipts/attachments-field' +import { + addDrafts, + attachmentsChanged, + draftsFromStored, + saveAttachments, + uploadDrafts, + type AttachmentDraft, +} from '../receipts/attachments-state' +import { ScanReceipt, type ScanResult } from '../receipts/scan-receipt' import { SplitEditor } from './split-editor' import { defaultFromDraft, @@ -269,10 +278,14 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { if (!dateTouched) setSpentAt(template.next_run_on) } const [notes, setNotes] = useState(expense?.notes ?? template?.notes ?? '') - const [receipt, setReceipt] = useState({ - file: null, - existingPath: expense?.receipt_path ?? null, + // Files on the expense. Null while editing until the person changes them, so a save before + // the stored list has loaded leaves it alone. + const storedFiles = useQuery({ + ...attachmentQueries.list(supabase, expense?.id ?? ''), + enabled: !!expense, }) + const [attachments, setAttachments] = useState(expense ? null : []) + const fileDrafts = attachments ?? draftsFromStored(storedFiles.data ?? []) const [recurrence, setRecurrence] = useState( template?.recurrence ?? (preset ? 'monthly' : 'none'), ) @@ -365,17 +378,28 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { const save = useMutation({ mutationFn: async () => { - let receiptPath = receipt.existingPath - if (receipt.file) { + const stored = expense ? (storedFiles.data ?? []) : [] + const filesChanged = !!attachments && attachmentsChanged(attachments, stored) + let paths: string[] = [] + if (attachments && filesChanged) { try { - receiptPath = await uploadReceipt(supabase, group.id, receipt.file, { - extension: receiptExtension(receipt.file), - contentType: receiptType(receipt.file) ?? receipt.file.type, - }) + paths = await uploadDrafts(supabase, group.id, attachments) } catch { throw new Error('receipt_upload_failed') } } + // The first file also rides along as receipt_path on a new expense; the rest are set once + // it exists (ADR 21). + const withFiles = async (saved: Awaited>) => { + if (filesChanged && (expense || paths.length > 1)) { + try { + await saveAttachments(supabase, saved.id, paths, stored) + } catch { + toast.error(t('expenses.attachments.partial')) + } + } + return saved + } const payload: ExpensePayload = { description: description.trim(), amount_cents: total, @@ -388,28 +412,31 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { category, notes: notes.trim() || null, spent_at: spentAt, - receipt_path: receiptPath, } if (expense && locked) { - return updateExpense(supabase, expense.id, { - description: payload.description, - category: payload.category, - notes: payload.notes, - spent_at: payload.spent_at, - receipt_path: payload.receipt_path, - }) + return withFiles( + await updateExpense(supabase, expense.id, { + description: payload.description, + category: payload.category, + notes: payload.notes, + spent_at: payload.spent_at, + }), + ) } if (expense) { - if (!keepSplit) return updateExpense(supabase, expense.id, payload) + if (!keepSplit) return withFiles(await updateExpense(supabase, expense.id, payload)) const { splits: _splits, split_type: _type, ...details } = payload - return updateExpense(supabase, expense.id, details) + return withFiles(await updateExpense(supabase, expense.id, details)) } - return createExpense(supabase, { - ...payload, - group_id: group.id, - ...(props.mode === 'create' && props.eventId ? { event_id: props.eventId } : {}), - ...(recurrence !== 'none' && !refund ? { recurrence } : {}), - }) + return withFiles( + await createExpense(supabase, { + ...payload, + receipt_path: paths[0] ?? null, + group_id: group.id, + ...(props.mode === 'create' && props.eventId ? { event_id: props.eventId } : {}), + ...(recurrence !== 'none' && !refund ? { recurrence } : {}), + }), + ) }, onSuccess: async (saved) => { invalidateMoney(queryClient, group.id, saved.id) @@ -459,6 +486,20 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { onError: (error) => toast.error(moneyErrorMessage(error)), }) + // A scan only fills the form; the person still checks it and saves. + const applyScan = ({ fill, photo }: ScanResult) => { + if (fill.description !== undefined) setDescription(fill.description) + if (fill.spentAt) setSpentAt(fill.spentAt) + if (fill.currency) { + setCurrencyPicked(true) + setCurrency(fill.currency) + } + if (fill.amount !== undefined) setAmount(fill.amount) + if (fill.draft) setDraft(fill.draft) + if (photo) setAttachments(addDrafts(fileDrafts, [photo]).drafts) + if (fill.amount !== undefined || fill.draft) toast.success(t('expenses.scan.applied')) + } + const wide = useMediaQuery('(min-width: 64rem)') const quickSplit = ( )} + {!locked && !template && ( + + )}

@@ -789,7 +841,7 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { {props.mode === 'recurring' && props.actions} ) : ( - + )} {!editing && !refund && ( const describe_ = (data: Record) => describeChanges(data, { viewerId: ME, nameOf }) describe('describeChanges', () => { + it('counts attachments added, removed or swapped', () => { + const line = (from: unknown, to: unknown) => + describe_({ changes: { attachments: { from, to } } }) + expect(line(1, 3)).toEqual(['2 attachments added']) + expect(line(2, 1)).toEqual(['1 attachment removed']) + expect(line(2, 2)).toEqual(['Attachments changed']) + expect(line(null, 1)).toEqual(['1 attachment added']) + }) + it('lists every changed field with its old and new value', () => { expect( describe_({ diff --git a/apps/web/src/features/expenses/history.ts b/apps/web/src/features/expenses/history.ts index 8ee3baa..fcc4925 100644 --- a/apps/web/src/features/expenses/history.ts +++ b/apps/web/src/features/expenses/history.ts @@ -36,6 +36,7 @@ const ORDER = [ 'notes', 'is_refund', 'receipt', + 'attachments', 'event_id', ] as const @@ -237,6 +238,18 @@ export function describeChanges( : t('expenses.history.change.receiptRemoved'), ) break + case 'attachments': { + const from = num(change.from) ?? 0 + const to = num(change.to) ?? 0 + lines.push( + to > from + ? t('expenses.history.change.attachmentsAdded', { count: to - from }) + : to < from + ? t('expenses.history.change.attachmentsRemoved', { count: from - to }) + : t('expenses.history.change.attachments'), + ) + break + } case 'event_id': lines.push(t('expenses.history.change.plan')) break diff --git a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx new file mode 100644 index 0000000..64da008 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -0,0 +1,228 @@ +import { attachmentQueries, moneyQueries, type ExpenseAttachment } from '@potluck/api' +import { t } from '@potluck/i18n' +import { IconButton, Sheet, SheetContent, Skeleton, buttonClassName, cn } from '@potluck/ui' +import { useQuery } from '@tanstack/react-query' +import { ChevronLeft, ChevronRight, ExternalLink, FileText } from 'lucide-react' +import { useEffect, useState } from 'react' +import { supabase } from '@/lib/supabase' +import { attachmentName } from './attachments-state' + +const asDraft = (a: ExpenseAttachment) => ({ contentType: a.content_type }) +const isPdf = (a: ExpenseAttachment) => a.content_type === 'application/pdf' + +/** + * Receipts and photos on the expense page. Each opens full screen; arrow keys and the buttons + * move between them. Links are signed for an hour, for members of the group only. + */ +export function AttachmentGallery({ + expenseId, + receiptPath, + description, +}: { + expenseId: string + /** Shown first while the list loads, so an expense with a receipt never flashes empty. */ + receiptPath: string | null + description: string +}) { + const list = useQuery(attachmentQueries.list(supabase, expenseId)) + const [open, setOpen] = useState(null) + if (!list.data) return receiptPath ?
: null + const files = list.data + if (files.length === 0) return null + const current = open === null ? null : files[open] + + return ( +
+
    + {files.map((file, index) => ( +
  • + setOpen(index)} + /> +
  • + ))} +
+ !next && setOpen(null)}> + {current && open !== null && ( + setOpen((open + step + files.length) % files.length)} + /> + )} + +
+ ) +} + +function Section({ + count, + loading, + children, +}: { + count: number + loading?: boolean + children?: React.ReactNode +}) { + return ( +
+

+ {count > 1 ? t('expenses.attachments.label') : t('expenses.detail.receipt')} +

+ {loading ? : children} +
+ ) +} + +function useSigned(file: ExpenseAttachment) { + return useQuery(moneyQueries.receiptUrl(supabase, file.path)).data +} + +function Thumb({ + file, + index, + large, + description, + onOpen, +}: { + file: ExpenseAttachment + index: number + large: boolean + description: string + onOpen: () => void +}) { + const url = useSigned(file) + const name = attachmentName(asDraft(file), index) + if (isPdf(file)) { + return url ? ( + + + {t('expenses.receiptUpload.pdf')} + + + ) : ( + + ) + } + return ( + + ) +} + +function Viewer({ + file, + index, + count, + description, + onMove, +}: { + file: ExpenseAttachment + index: number + count: number + description: string + onMove: (step: 1 | -1) => void +}) { + const url = useSigned(file) + useEffect(() => { + if (count < 2) return + const onKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'ArrowRight') onMove(1) + if (e.key === 'ArrowLeft') onMove(-1) + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [count, onMove]) + return ( + + {count > 1 ? ( + } + variant="secondary" + onClick={() => onMove(-1)} + /> + ) : ( + + )} + {url && ( + + + {isPdf(file) + ? t('expenses.attachments.openPdf') + : t('expenses.attachments.openOriginal')} + + )} + {count > 1 ? ( + } + variant="secondary" + onClick={() => onMove(1)} + /> + ) : ( + + )} + + } + > +
+ {isPdf(file) ? ( + + ) : url ? ( + {t('expenses.attachments.alt', + ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/features/expenses/receipts/attachments-field.tsx b/apps/web/src/features/expenses/receipts/attachments-field.tsx new file mode 100644 index 0000000..5cf11a2 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachments-field.tsx @@ -0,0 +1,179 @@ +import { MAX_ATTACHMENTS, moneyQueries } from '@potluck/api' +import { t } from '@potluck/i18n' +import { IconButton, Spinner, toast } from '@potluck/ui' +import { useQuery } from '@tanstack/react-query' +import { Camera, FileText, X } from 'lucide-react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { Help } from '@/features/help/help' +import { supabase } from '@/lib/supabase' +import { RECEIPT_ACCEPT } from '../form/receipt' +import { + addDrafts, + attachmentName, + draftFromFile, + isPdf, + type AttachmentDraft, +} from './attachments-state' +import { prepareImage } from './image' + +const MAX_BYTES = 10 * 1024 * 1024 + +/** Receipts, photos and PDFs on the expense form: up to five, shrunk on the device first. */ +export function AttachmentsField({ + value, + onChange, +}: { + value: AttachmentDraft[] + onChange: (next: AttachmentDraft[]) => void +}) { + const inputRef = useRef(null) + const id = useId() + const [preparing, setPreparing] = useState(0) + // The latest list, for files that finish shrinking after the person changed something else. + const latest = useRef(value) + useEffect(() => { + latest.current = value + }, [value]) + const full = value.length + preparing >= MAX_ATTACHMENTS + + const pick = async (files: File[]) => { + const room = MAX_ATTACHMENTS - latest.current.length - preparing + if (files.length > room) toast.message(t('expenses.attachments.leftOut')) + const taken = files.slice(0, Math.max(0, room)) + const usable = taken.filter((file) => { + if (!draftFromFile(file)) { + toast.error(t('expenses.receiptUpload.wrongType')) + return false + } + return true + }) + if (usable.length === 0) return + setPreparing((n) => n + usable.length) + const ready = await Promise.all(usable.map(prepareImage)) + setPreparing((n) => n - usable.length) + const drafts = ready.flatMap((file) => { + if (file.size > MAX_BYTES) { + toast.error(t('expenses.receiptUpload.tooBig')) + return [] + } + const draft = draftFromFile(file) + return draft ? [draft] : [] + }) + onChange(addDrafts(latest.current, drafts).drafts) + } + + return ( +
+
+ + {t('expenses.attachments.label')} + {value.length > 0 && ( + + {t('expenses.attachments.count', { count: value.length, max: MAX_ATTACHMENTS })} + + )} + + +
+ { + const files = [...(e.target.files ?? [])] + e.target.value = '' + void pick(files) + }} + /> +
    + {value.map((draft, index) => ( + onChange(value.filter((d) => d.key !== draft.key))} + /> + ))} + {Array.from({ length: preparing }, (_, i) => ( +
  • + +
  • + ))} + {!full && ( +
  • + +
  • + )} +
+ {full && value.length >= MAX_ATTACHMENTS && ( +

{t('expenses.attachments.full')}

+ )} +
+ ) +} + +function Tile({ + draft, + index, + onRemove, +}: { + draft: AttachmentDraft + index: number + onRemove: () => void +}) { + const local = useMemo(() => (draft.file ? URL.createObjectURL(draft.file) : null), [draft.file]) + useEffect( + () => () => { + if (local) URL.revokeObjectURL(local) + }, + [local], + ) + const stored = useQuery({ + ...moneyQueries.receiptUrl(supabase, draft.path ?? ''), + enabled: !draft.file && !!draft.path && !isPdf(draft), + }) + const src = local ?? stored.data + const name = attachmentName(draft, index) + return ( +
  • + {!isPdf(draft) && src ? ( + {name} + ) : ( + + + {name} + + )} + } + onClick={onRemove} + className="shadow-sm absolute -top-2 -right-2 size-7 border border-line bg-card" + /> +
  • + ) +} diff --git a/apps/web/src/features/expenses/receipts/attachments-state.test.ts b/apps/web/src/features/expenses/receipts/attachments-state.test.ts new file mode 100644 index 0000000..466669b --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachments-state.test.ts @@ -0,0 +1,104 @@ +import type { ExpenseAttachment } from '@potluck/api' +import { describe, expect, it, vi } from 'vitest' +import { + addDrafts, + attachmentsChanged, + draftFromFile, + draftsFromStored, + isPdf, + saveAttachments, + uploadDrafts, + type AttachmentDraft, +} from './attachments-state' +import { fitWithin, prepareImage } from './image' + +const api = vi.hoisted(() => ({ + uploadReceipt: vi.fn( + async (_c: unknown, groupId: string, _f: unknown, o: { extension: string }) => + `${groupId}/new.${o.extension}`, + ), + setExpenseAttachments: vi.fn(async () => []), + removeReceiptFiles: vi.fn(async () => undefined), +})) +vi.mock('@potluck/api', async (original) => ({ ...(await original()), ...api })) + +const stored = (position: number, path: string, type = 'image/jpeg'): ExpenseAttachment => ({ + expense_id: 'e1', + position, + path, + content_type: type, + uploaded_by: 'u1', + created_at: '2026-11-13T10:00:00Z', +}) +const file = (name: string, type: string) => new File(['x'], name, { type }) +const client = {} as never + +describe('attachment drafts', () => { + it('reads stored files in order', () => { + const drafts = draftsFromStored([stored(1, 'g/b.pdf', 'application/pdf'), stored(0, 'g/a.jpg')]) + expect(drafts.map((d) => [d.path, isPdf(d)])).toEqual([ + ['g/a.jpg', false], + ['g/b.pdf', true], + ]) + }) + + it('takes photos and PDFs, nothing else', () => { + expect(draftFromFile(file('r.png', 'image/png'))?.contentType).toBe('image/png') + expect(draftFromFile(file('r.HEIC', ''))?.contentType).toBe('image/heic') + expect(draftFromFile(file('r.txt', 'text/plain'))).toBeNull() + }) + + it('adds up to five and counts the rest', () => { + const four = draftsFromStored([0, 1, 2, 3].map((i) => stored(i, `g/${i}.jpg`))) + const added = [ + draftFromFile(file('a.jpg', 'image/jpeg'))!, + draftFromFile(file('b.jpg', 'image/jpeg'))!, + ] + const { drafts, leftOut } = addDrafts(four, added) + expect(drafts).toHaveLength(5) + expect(leftOut).toBe(1) + }) + + it('notices new files, removals and a new order', () => { + const list = [stored(0, 'g/a.jpg'), stored(1, 'g/b.jpg')] + const same = draftsFromStored(list) + expect(attachmentsChanged(same, list)).toBe(false) + expect(attachmentsChanged([same[1]!, same[0]!], list)).toBe(true) + expect(attachmentsChanged(same.slice(1), list)).toBe(true) + expect(attachmentsChanged([...same, draftFromFile(file('c.jpg', 'image/jpeg'))!], list)).toBe( + true, + ) + }) + + it('uploads only new files and keeps the order', async () => { + const drafts: AttachmentDraft[] = [ + { key: 'a', path: 'g/a.jpg', contentType: 'image/jpeg' }, + draftFromFile(file('scan.png', 'image/png'))!, + ] + expect(await uploadDrafts(client, 'g', drafts)).toEqual(['g/a.jpg', 'g/new.png']) + expect(api.uploadReceipt).toHaveBeenCalledTimes(1) + }) + + it('saves the list, then deletes what left it', async () => { + await saveAttachments(client, 'e1', ['g/b.jpg'], [stored(0, 'g/a.jpg'), stored(1, 'g/b.jpg')]) + expect(api.setExpenseAttachments).toHaveBeenCalledWith(client, 'e1', ['g/b.jpg']) + expect(api.removeReceiptFiles).toHaveBeenCalledWith(client, ['g/a.jpg']) + }) +}) + +describe('photos', () => { + it('fit inside 2000 pixels without growing', () => { + expect(fitWithin(4032, 3024)).toEqual({ width: 2000, height: 1500 }) + expect(fitWithin(1000, 3000)).toEqual({ width: 667, height: 2000 }) + expect(fitWithin(800, 600)).toEqual({ width: 800, height: 600 }) + expect(fitWithin(0, 0)).toEqual({ width: 1, height: 1 }) + }) + + it('come back untouched when they cannot be decoded here', async () => { + const pdf = file('r.pdf', 'application/pdf') + expect(await prepareImage(pdf)).toBe(pdf) + // jsdom has no createImageBitmap: the photo goes up as it was. + const photo = file('r.jpg', 'image/jpeg') + expect(await prepareImage(photo)).toBe(photo) + }) +}) diff --git a/apps/web/src/features/expenses/receipts/attachments-state.ts b/apps/web/src/features/expenses/receipts/attachments-state.ts new file mode 100644 index 0000000..df07bc5 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachments-state.ts @@ -0,0 +1,96 @@ +import { + MAX_ATTACHMENTS, + removeReceiptFiles, + setExpenseAttachments, + uploadReceipt, + type ExpenseAttachment, + type PotluckClient, +} from '@potluck/api' +import { t } from '@potluck/i18n' +import { receiptExtension, receiptType } from '../form/receipt' + +/** One file in the form: already stored (`path`) or picked and waiting to upload (`file`). */ +export type AttachmentDraft = + | { key: string; path: string; contentType: string; file?: undefined } + | { key: string; file: File; contentType: string; path?: undefined } + +let seq = 0 +const nextKey = () => `attachment-${(seq += 1)}` + +export function draftsFromStored(stored: readonly ExpenseAttachment[]): AttachmentDraft[] { + return [...stored] + .sort((a, b) => a.position - b.position) + .map((a) => ({ key: a.path, path: a.path, contentType: a.content_type })) +} + +/** A picked file as a draft, or null when receipts cannot take its type. */ +export function draftFromFile(file: File): AttachmentDraft | null { + const contentType = receiptType(file) + return contentType ? { key: nextKey(), file, contentType } : null +} + +export const isPdf = (draft: Pick) => + draft.contentType === 'application/pdf' + +/** A label for the nth file: "Photo 2", "PDF 1". */ +export function attachmentName(draft: Pick, index: number) { + return isPdf(draft) + ? t('expenses.attachments.pdf', { number: index + 1 }) + : t('expenses.attachments.photo', { number: index + 1 }) +} + +/** Adds picked files after the current ones, up to the limit. `leftOut` counts what did not fit. */ +export function addDrafts( + current: readonly AttachmentDraft[], + added: readonly AttachmentDraft[], +): { drafts: AttachmentDraft[]; leftOut: number } { + const room = Math.max(0, MAX_ATTACHMENTS - current.length) + return { + drafts: [...current, ...added.slice(0, room)], + leftOut: Math.max(0, added.length - room), + } +} + +/** Whether the list differs from what is stored (new files, removals or a new order). */ +export function attachmentsChanged( + drafts: readonly AttachmentDraft[], + stored: readonly ExpenseAttachment[], +): boolean { + const before = draftsFromStored(stored).map((d) => d.path) + return drafts.length !== before.length || drafts.some((d, i) => d.path !== before[i]) +} + +/** Uploads the new files in order and returns every path, in the form's order. */ +export async function uploadDrafts( + client: PotluckClient, + groupId: string, + drafts: readonly AttachmentDraft[], +): Promise { + return Promise.all( + drafts.map((d) => + d.path !== undefined + ? d.path + : uploadReceipt(client, groupId, d.file, { + extension: receiptExtension(d.file), + contentType: d.contentType, + }), + ), + ) +} + +/** + * Saves the list on an expense and deletes the files that left it. Deleting is best effort: the + * bucket lets only the uploader or an admin remove a file. + */ +export async function saveAttachments( + client: PotluckClient, + expenseId: string, + paths: string[], + stored: readonly ExpenseAttachment[], +): Promise { + await setExpenseAttachments(client, expenseId, paths) + await removeReceiptFiles( + client, + stored.map((a) => a.path).filter((p) => !paths.includes(p)), + ) +} diff --git a/apps/web/src/features/expenses/receipts/image.ts b/apps/web/src/features/expenses/receipts/image.ts new file mode 100644 index 0000000..9618dc2 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/image.ts @@ -0,0 +1,51 @@ +/** Longest side of a stored photo: sharp enough to read every line of a receipt. */ +export const MAX_EDGE = 2000 +const QUALITY = 0.82 + +/** The size that fits `width` x `height` inside `max` on its longest side, never upscaled. */ +export function fitWithin(width: number, height: number, max = MAX_EDGE) { + const scale = Math.min(1, max / Math.max(width, height, 1)) + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +const RESIZABLE = new Set(['image/jpeg', 'image/png', 'image/webp']) + +/** + * Shrinks a photo before it is uploaded or read: at most MAX_EDGE on its longest side, as JPEG + * on white (a phone photo of a receipt goes from about 4 MB to about 400 KB). The camera's + * orientation is applied. Anything the browser cannot decode (HEIC outside Safari, PDFs) and + * any photo that would not get smaller is returned as it was. + */ +export async function prepareImage(file: File): Promise { + if (!RESIZABLE.has(file.type) && !(file.type === 'image/heic' && canDecodeHeic())) return file + try { + const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }) + const size = fitWithin(bitmap.width, bitmap.height) + const resized = size.width !== bitmap.width || size.height !== bitmap.height + const canvas = document.createElement('canvas') + canvas.width = size.width + canvas.height = size.height + const context = canvas.getContext('2d') + if (!context) return file + context.fillStyle = '#ffffff' + context.fillRect(0, 0, size.width, size.height) + context.drawImage(bitmap, 0, 0, size.width, size.height) + bitmap.close() + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/jpeg', QUALITY), + ) + if (!blob || (!resized && blob.size >= file.size)) return file + const name = `${file.name.replace(/\.[^.]*$/, '') || 'photo'}.jpg` + return new File([blob], name, { type: 'image/jpeg', lastModified: file.lastModified }) + } catch { + return file + } +} + +/** Safari decodes HEIC photos; other browsers upload them untouched. */ +function canDecodeHeic(): boolean { + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent) +} diff --git a/apps/web/src/features/expenses/receipts/ocr.ts b/apps/web/src/features/expenses/receipts/ocr.ts new file mode 100644 index 0000000..fc4c4af --- /dev/null +++ b/apps/web/src/features/expenses/receipts/ocr.ts @@ -0,0 +1,63 @@ +import type { Worker } from 'tesseract.js' + +export type ReadStage = 'loading' | 'reading' + +export interface ReadProgress { + stage: ReadStage + /** 0 to 1 within the stage. */ + progress: number +} + +/** The reader could not start: almost always the first scan without a connection. */ +export class ReaderUnavailableError extends Error { + constructor(cause: unknown) { + super('reader_unavailable', { cause }) + } +} + +/** + * Reads the text on a photo, on the device (ADR 21). tesseract.js is imported only here, so it + * is fetched the first time someone scans and never weighs on the app bundle; its worker, core + * and English model come from our own origin (`ocr-assets.ts`) and the service worker keeps them, + * so later scans work offline. Each read starts a worker and ends it, so the memory it takes + * (around 100 MB) goes back as soon as the text is out. Aborting `signal` stops it. + */ +export async function readReceiptText( + image: Blob, + { onProgress, signal }: { onProgress?: (p: ReadProgress) => void; signal?: AbortSignal } = {}, +): Promise { + signal?.throwIfAborted() + const base = new URL(`${__OCR_PATH__}/`, window.location.origin).href + let worker: Worker + try { + const { createWorker, OEM } = await import('tesseract.js') + signal?.throwIfAborted() + worker = await createWorker('eng', OEM.LSTM_ONLY, { + workerPath: `${base}worker.min.js`, + corePath: `${base}core`, + langPath: `${base}lang`, + workerBlobURL: false, + logger: (m: { status: string; progress: number }) => + onProgress?.({ + stage: m.status === 'recognizing text' ? 'reading' : 'loading', + progress: m.progress, + }), + }) + } catch (error) { + if (signal?.aborted) throw signal.reason + throw new ReaderUnavailableError(error) + } + let stop = () => {} + const stopped = new Promise((_, reject) => { + stop = () => reject(signal?.reason) + signal?.addEventListener('abort', stop, { once: true }) + }) + try { + signal?.throwIfAborted() + const { data } = await Promise.race([worker.recognize(image), stopped]) + return data.text + } finally { + signal?.removeEventListener('abort', stop) + await worker.terminate().catch(() => undefined) + } +} diff --git a/apps/web/src/features/expenses/receipts/receipts.test.tsx b/apps/web/src/features/expenses/receipts/receipts.test.tsx new file mode 100644 index 0000000..02772cf --- /dev/null +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -0,0 +1,221 @@ +import { formatMoney, t } from '@potluck/i18n' +import { Toaster } from '@potluck/ui' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Ocr from './ocr' +import { resetDb } from '@/test/supabase' +import { initialDraft } from '../form/split-state' +import { renderWithClient, seededClient } from '../test-utils' +import { AttachmentsField } from './attachments-field' +import type { AttachmentDraft } from './attachments-state' +import { ScanReceipt, type ScanResult } from './scan-receipt' + +vi.mock('@/lib/supabase', () => import('@/test/supabase')) + +const ocr = vi.hoisted(() => ({ text: '', fail: false })) +vi.mock('./ocr', async (original) => { + const real = await original() + return { + ...real, + readReceiptText: vi.fn( + async (_image: Blob, { onProgress }: { onProgress?: (p: unknown) => void } = {}) => { + onProgress?.({ stage: 'loading', progress: 0.5 }) + onProgress?.({ stage: 'reading', progress: 0.4 }) + if (ocr.fail) throw new real.ReaderUnavailableError(new Error('fetch failed')) + return ocr.text + }, + ), + } +}) + +const RECEIPT = `JOE'S DINER +11/08/2026 7:42 PM +2 Cheeseburger 25.00 +Fries 4.75 +SUBTOTAL 29.75 +Sales Tax 2.45 +TOTAL 32.20` + +beforeEach(() => { + resetDb() + ocr.text = RECEIPT + ocr.fail = false + URL.createObjectURL = vi.fn(() => 'blob:photo') + URL.revokeObjectURL = vi.fn() +}) + +const photo = () => new File(['jpg'], 'dinner.jpg', { type: 'image/jpeg' }) + +function renderScan({ canAttach = true, description = '' } = {}) { + const onApply = vi.fn<(result: ScanResult) => void>() + const view = renderWithClient( + <> + + + , + seededClient(), + ) + const input = view.container.querySelector('input[type=file]') as HTMLInputElement + const pick = async (file = photo()) => { + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }) + }) + } + return { onApply, pick } +} + +describe('ScanReceipt', () => { + it('reads a photo and offers each part to tick, filling the form only on request', async () => { + const { onApply, pick } = renderScan() + expect(screen.getByRole('button', { name: t('expenses.scan.button') })).toBeVisible() + await pick() + + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + expect(within(sheet).getByText("Joe's Diner")).toBeInTheDocument() + expect(within(sheet).getByText('2× Cheeseburger')).toBeInTheDocument() + expect(within(sheet).getByText(t('expenses.scan.items', { count: 2 }))).toBeInTheDocument() + expect(onApply).not.toHaveBeenCalled() + + // Leave the date out. + fireEvent.click( + within(sheet).getByRole('checkbox', { name: new RegExp(`^${t('expenses.scan.date')}`) }), + ) + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + + expect(onApply).toHaveBeenCalledTimes(1) + const [{ fill, photo: attached }] = onApply.mock.calls[0]! + expect(fill.description).toBe("Joe's Diner") + expect(fill.spentAt).toBeUndefined() + expect(fill.amount).toBe(3220) + expect(fill.draft?.items.map((i) => i.name)).toEqual(['2× Cheeseburger', 'Fries']) + expect(attached?.contentType).toBe('image/jpeg') + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('can leave the photo off', async () => { + const { onApply, pick } = renderScan() + await pick() + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + fireEvent.click(within(sheet).getByRole('checkbox', { name: t('expenses.scan.attach') })) + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + expect(onApply.mock.calls[0]![0].photo).toBeNull() + }) + + it('says when the items and the total disagree', async () => { + ocr.text = 'Cafe\nTea 3.00\nTOTAL 4.00' + const { pick } = renderScan() + await pick() + expect( + await screen.findByText( + t('expenses.scan.itemsOff', { + items: formatMoney(300, 'USD'), + total: formatMoney(400, 'USD'), + }), + ), + ).toBeInTheDocument() + }) + + it('offers to attach the photo when nothing reads as prices', async () => { + ocr.text = 'a blurry thumb' + const { onApply, pick } = renderScan() + await pick() + expect(await screen.findByText(t('expenses.scan.nothing'))).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: t('expenses.scan.attachOnly') })) + expect(onApply.mock.calls[0]![0]).toMatchObject({ fill: {} }) + expect(onApply.mock.calls[0]![0].photo).not.toBeNull() + }) + + it('explains a reader that would not start', async () => { + ocr.fail = true + const { pick } = renderScan() + await pick() + expect(await screen.findByText(t('expenses.scan.failed'))).toBeInTheDocument() + expect(screen.getByRole('button', { name: t('expenses.scan.again') })).toBeVisible() + }) + + it('asks for a connection when the first scan happens offline', async () => { + ocr.fail = true + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) + const { pick } = renderScan() + await pick() + expect(await screen.findByText(t('expenses.scan.offline'))).toBeInTheDocument() + vi.restoreAllMocks() + }) + + it('does not offer to attach when the expense is full', async () => { + const { pick } = renderScan({ canAttach: false }) + await pick() + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + expect(within(sheet).queryByRole('checkbox', { name: t('expenses.scan.attach') })).toBeNull() + }) +}) + +function Field({ initial = [] as AttachmentDraft[] }) { + const [value, setValue] = useState(initial) + return ( + <> + + + + ) +} + +describe('AttachmentsField', () => { + const add = async (files: File[]) => { + const input = screen.getByLabelText(t('expenses.attachments.label'), { + selector: 'input', + }) + await act(async () => { + fireEvent.change(input, { target: { files } }) + }) + } + + it('adds photos and PDFs, shows them and removes them', async () => { + renderWithClient(, seededClient()) + expect(screen.getByRole('button', { name: t('expenses.attachments.addLabel') })).toBeVisible() + await add([photo(), new File(['pdf'], 'bill.pdf', { type: 'application/pdf' })]) + + const one = t('expenses.attachments.photo', { number: 1 }) + expect(await screen.findByRole('img', { name: one })).toHaveAttribute('src', 'blob:photo') + expect( + screen.getByRole('img', { name: t('expenses.attachments.pdf', { number: 2 }) }), + ).toBeInTheDocument() + expect(screen.getByText(t('expenses.attachments.count', { count: 2, max: 5 }))).toBeVisible() + + fireEvent.click( + screen.getByRole('button', { name: t('expenses.attachments.remove', { name: one }) }), + ) + expect(screen.queryByRole('img', { name: one })).toBeNull() + expect(screen.getByText(t('expenses.attachments.count', { count: 1, max: 5 }))).toBeVisible() + }) + + it('refuses other files and stops at five', async () => { + renderWithClient(, seededClient()) + await add([new File(['x'], 'notes.txt', { type: 'text/plain' })]) + expect(await screen.findByText(t('expenses.receiptUpload.wrongType'))).toBeInTheDocument() + + await add( + Array.from({ length: 6 }, (_, i) => new File(['x'], `p${i}.jpg`, { type: 'image/jpeg' })), + ) + expect(await screen.findByText(t('expenses.attachments.leftOut'))).toBeInTheDocument() + expect(screen.getByText(t('expenses.attachments.full'))).toBeVisible() + expect(screen.queryByRole('button', { name: t('expenses.attachments.addLabel') })).toBeNull() + }) + + it('refuses a file over 10 MB', async () => { + renderWithClient(, seededClient()) + const big = new File(['x'], 'huge.pdf', { type: 'application/pdf' }) + Object.defineProperty(big, 'size', { value: 11 * 1024 * 1024 }) + await add([big]) + expect(await screen.findByText(t('expenses.receiptUpload.tooBig'))).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts new file mode 100644 index 0000000..2606bf6 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -0,0 +1,137 @@ +import type { ReceiptScan } from '@potluck/core' +import { describe, expect, it } from 'vitest' +import { initialDraft, itemsTotal } from '../form/split-state' +import { defaultChoice, fillFromScan, scanFoundSomething, scanItemsTotal } from './scan-fill' + +const scan = (over: Partial = {}): ReceiptScan => ({ + merchant: "Joe's Diner", + date: '2026-11-08', + currency: null, + totalCents: 5617, + subtotalCents: 4450, + taxCents: 367, + tipCents: 800, + discountCents: null, + items: [ + { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, + { description: 'Fries', amountCents: 1950, quantity: 1 }, + ], + ...over, +}) + +const all = { name: true, date: true, total: true, items: true } +const people = ['me', 'ben', 'cleo'] + +describe('scan review defaults', () => { + it('ticks what it found, but not a name over one already typed', () => { + expect(defaultChoice(scan(), '')).toEqual(all) + expect(defaultChoice(scan(), 'Birthday dinner').name).toBe(false) + expect( + defaultChoice(scan({ merchant: null, date: null, totalCents: null, items: [] }), ''), + ).toEqual({ name: false, date: false, total: false, items: false }) + }) + + it('knows when a scan found nothing usable', () => { + expect(scanFoundSomething(scan())).toBe(true) + expect(scanFoundSomething(scan({ items: [] }))).toBe(true) + expect(scanFoundSomething(scan({ totalCents: null, items: [] }))).toBe(false) + }) + + it('adds up the lines the way the form does', () => { + expect(scanItemsTotal(scan())).toBe(5617) + expect(scanItemsTotal(scan({ taxCents: null, tipCents: null, discountCents: 50 }))).toBe(4400) + }) +}) + +describe('fillFromScan', () => { + const draft = initialDraft(people) + + it('fills name, date and a by-item split with everyone on every line', () => { + const fill = fillFromScan(scan(), all, { draft, currency: 'USD', memberIds: people }) + expect(fill.description).toBe("Joe's Diner") + expect(fill.spentAt).toBe('2026-11-08') + expect(fill.currency).toBeUndefined() + expect(fill.draft?.mode).toBe('itemized') + expect(fill.draft?.items.map((i) => [i.name, i.amountCents, i.userIds])).toEqual([ + ['2× Cheeseburger', 2500, people], + ['Fries', 1950, people], + ]) + expect([fill.draft?.taxCents, fill.draft?.tipCents, fill.draft?.tipPercent]).toEqual([ + 367, + 800, + null, + ]) + expect(fill.amount).toBe(5617) + }) + + it('adds a line for whatever the receipt total has beyond its items', () => { + const fill = fillFromScan(scan({ tipCents: null }), all, { + draft, + currency: 'USD', + memberIds: people, + }) + expect(fill.draft?.items.at(-1)).toMatchObject({ name: 'Everything else', amountCents: 800 }) + expect(itemsTotal(fill.draft!)).toBe(5617) + expect(fill.amount).toBe(5617) + }) + + it('uses only the people already in the split', () => { + const two = { ...draft, participants: ['me', 'ben'] } + const fill = fillFromScan(scan(), all, { draft: two, currency: 'USD', memberIds: people }) + expect(fill.draft?.items[0]?.userIds).toEqual(['me', 'ben']) + const nobody = { ...draft, participants: [] } + const fallback = fillFromScan(scan(), all, { + draft: nobody, + currency: 'USD', + memberIds: people, + }) + expect(fallback.draft?.items[0]?.userIds).toEqual(people) + }) + + it('uses the total alone, leaving by item for an even split', () => { + const itemized = { ...draft, mode: 'itemized' as const } + const fill = fillFromScan( + scan(), + { ...all, items: false }, + { draft: itemized, currency: 'USD', memberIds: people }, + ) + expect(fill.amount).toBe(5617) + expect(fill.draft?.mode).toBe('equal') + const plain = fillFromScan( + scan(), + { ...all, items: false }, + { + draft, + currency: 'USD', + memberIds: people, + }, + ) + expect(plain.draft).toBeUndefined() + }) + + it('leaves out what was unticked, and switches currency only when amounts are used', () => { + const yen = scan({ currency: 'JPY', totalCents: 507, items: [] }) + expect( + fillFromScan( + yen, + { name: false, date: false, total: true, items: false }, + { + draft, + currency: 'USD', + memberIds: people, + }, + ), + ).toEqual({ currency: 'JPY', amount: 507 }) + expect( + fillFromScan( + yen, + { name: true, date: false, total: false, items: false }, + { + draft, + currency: 'USD', + memberIds: people, + }, + ), + ).toEqual({ description: "Joe's Diner" }) + }) +}) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts new file mode 100644 index 0000000..e15d4ac --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -0,0 +1,100 @@ +import type { ReceiptScan } from '@potluck/core' +import { t } from '@potluck/i18n' +import { itemsTotal, newItem, type SplitDraft } from '../form/split-state' + +/** Which parts of a scan the person kept (each is a checkbox in the review). */ +export interface ScanChoice { + name: boolean + date: boolean + total: boolean + items: boolean +} + +/** What a scan changes in the expense form. Missing keys leave that field alone. */ +export interface ScanFill { + description?: string + spentAt?: string + currency?: string + amount?: number + draft?: SplitDraft +} + +/** What the review starts with ticked: everything it found, except a name over a typed one. */ +export function defaultChoice(scan: ReceiptScan, description: string): ScanChoice { + return { + name: !!scan.merchant && description.trim() === '', + date: !!scan.date, + total: scan.totalCents !== null, + items: scan.items.length > 0, + } +} + +/** Whether the scan found anything the form could use. */ +export function scanFoundSomething(scan: ReceiptScan): boolean { + return scan.totalCents !== null || scan.items.length > 0 +} + +/** The bill the items, tax, tip and discount make, in minor units. */ +export function scanItemsTotal(scan: ReceiptScan): number { + return ( + scan.items.reduce((a, i) => a + i.amountCents, 0) + + (scan.taxCents ?? 0) + + (scan.tipCents ?? 0) - + (scan.discountCents ?? 0) + ) +} + +/** + * Turns the parts of a scan the person kept into form changes. Items go into the by-item editor + * with everyone in the split on every line, ready to be untapped; tax, tip and discount come + * along. When the receipt's total is more than its lines, the difference becomes one more line + * so the expense matches the paper. A receipt in another currency switches the form to it, since + * its amounts are in that currency's units. + */ +export function fillFromScan( + scan: ReceiptScan, + choice: ScanChoice, + { draft, currency, memberIds }: { draft: SplitDraft; currency: string; memberIds: string[] }, +): ScanFill { + const fill: ScanFill = {} + if (choice.name && scan.merchant) fill.description = scan.merchant.slice(0, 120) + if (choice.date && scan.date) fill.spentAt = scan.date + const usesAmounts = + (choice.total && scan.totalCents !== null) || (choice.items && scan.items.length > 0) + if (usesAmounts && scan.currency && scan.currency !== currency) fill.currency = scan.currency + + if (choice.items && scan.items.length > 0) { + const people = draft.participants.length > 0 ? draft.participants : memberIds + const items = scan.items.map((item) => ({ + ...newItem([...people]), + name: (item.quantity > 1 + ? t('expenses.scan.quantity', { count: item.quantity, name: item.description }) + : item.description + ).slice(0, 80), + amountCents: item.amountCents, + })) + const next: SplitDraft = { + ...draft, + mode: 'itemized', + items, + taxCents: scan.taxCents, + tipCents: scan.tipCents, + discountCents: scan.discountCents, + tipPercent: null, + } + const missing = + choice.total && scan.totalCents !== null ? scan.totalCents - itemsTotal(next) : 0 + if (missing > 0) { + next.items = [ + ...items, + { ...newItem([...people]), name: t('expenses.scan.rest'), amountCents: missing }, + ] + } + fill.draft = next + fill.amount = itemsTotal(next) + } else if (choice.total && scan.totalCents !== null) { + fill.amount = scan.totalCents + if (draft.mode === 'itemized') fill.draft = { ...draft, mode: 'equal' } + } + return fill +} diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx new file mode 100644 index 0000000..d96f37b --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -0,0 +1,456 @@ +import { parseReceipt, receiptDateOrder, type ReceiptScan } from '@potluck/core' +import { formatDate, formatMoney, intlLocale, t } from '@potluck/i18n' +import { + Button, + Checkbox, + ProgressRing, + Sheet, + SheetContent, + cn, + usePrefersReducedMotion, +} from '@potluck/ui' +import { motion } from 'motion/react' +import { ScanLine } from 'lucide-react' +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react' +import { Help } from '@/features/help/help' +import { todayIn } from '../format' +import type { SplitDraft } from '../form/split-state' +import { Money } from '../money' +import { draftFromFile, type AttachmentDraft } from './attachments-state' +import { prepareImage } from './image' +import { ReaderUnavailableError, readReceiptText, type ReadStage } from './ocr' +import { + defaultChoice, + fillFromScan, + scanFoundSomething, + scanItemsTotal, + type ScanChoice, + type ScanFill, +} from './scan-fill' + +export interface ScanResult { + fill: ScanFill + /** The photo to attach, when the person kept that box ticked. */ + photo: AttachmentDraft | null +} + +type State = + | { phase: 'idle' } + | { phase: 'reading'; photo: File; url: string; stage: ReadStage; progress: number } + | { + phase: 'review' + photo: File + url: string + scan: ReceiptScan + choice: ScanChoice + attach: boolean + } + | { phase: 'empty'; photo: File; url: string } + | { phase: 'failed'; reason: 'offline' | 'failed' } + +/** + * "Scan a receipt": a photo is read on the device and what it says is offered for the expense + * form, part by part. It only ever fills the form; saving stays with the person (ADR 21). + */ +export function ScanReceipt({ + currency, + description, + draft, + memberIds, + timeZone, + canAttach, + onApply, +}: { + currency: string + description: string + draft: SplitDraft + memberIds: string[] + timeZone: string + canAttach: boolean + onApply: (result: ScanResult) => void +}) { + const inputRef = useRef(null) + const abortRef = useRef(null) + const [state, setState] = useState({ phase: 'idle' }) + const url = 'url' in state ? state.url : null + useEffect( + () => () => { + if (url) URL.revokeObjectURL(url) + }, + [url], + ) + useEffect(() => () => abortRef.current?.abort(), []) + + const read = async (picked: File) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + const photo = await prepareImage(picked) + const photoUrl = URL.createObjectURL(photo) + setState({ phase: 'reading', photo, url: photoUrl, stage: 'loading', progress: 0 }) + try { + const text = await readReceiptText(photo, { + signal: controller.signal, + onProgress: ({ stage, progress }) => + setState((s) => (s.phase === 'reading' ? { ...s, stage, progress } : s)), + }) + const scan = parseReceipt(text, { + currency, + dateOrder: receiptDateOrder(currency, intlLocale()), + today: todayIn(timeZone), + }) + setState( + scanFoundSomething(scan) + ? { + phase: 'review', + photo, + url: photoUrl, + scan, + choice: defaultChoice(scan, description), + attach: canAttach, + } + : { phase: 'empty', photo, url: photoUrl }, + ) + } catch (error) { + if (controller.signal.aborted) return + URL.revokeObjectURL(photoUrl) + setState({ + phase: 'failed', + reason: error instanceof ReaderUnavailableError && !navigator.onLine ? 'offline' : 'failed', + }) + } + } + + const close = () => { + abortRef.current?.abort() + setState({ phase: 'idle' }) + } + const pickAgain = () => inputRef.current?.click() + const attachDraft = (photo: File, attach: boolean) => (attach ? draftFromFile(photo) : null) + + const apply = () => { + if (state.phase !== 'review') return + onApply({ + fill: fillFromScan(state.scan, state.choice, { draft, currency, memberIds }), + photo: attachDraft(state.photo, state.attach && canAttach), + }) + setState({ phase: 'idle' }) + } + + const footer = + state.phase === 'reading' ? ( + + ) : state.phase === 'review' ? ( +
    + + +
    + ) : state.phase === 'empty' ? ( +
    + + {canAttach && ( + + )} +
    + ) : state.phase === 'failed' ? ( + + ) : null + + return ( + <> + { + const file = e.target.files?.[0] + e.target.value = '' + if (file) void read(file) + }} + /> + + + + + !open && close()}> + + {state.phase === 'reading' && ( + + )} + {state.phase === 'review' && ( + setState({ ...state, choice })} + attach={state.attach} + canAttach={canAttach} + onAttach={(attach) => setState({ ...state, attach })} + /> + )} + {state.phase === 'empty' && ( + {t('expenses.scan.nothing')} + )} + {state.phase === 'failed' && ( + + {state.reason === 'offline' ? t('expenses.scan.offline') : t('expenses.scan.failed')} + + )} + + + + ) +} + +/** The photo under a sweeping line while the reader works. */ +function Reading({ url, stage, progress }: { url: string; stage: ReadStage; progress: number }) { + const reduced = usePrefersReducedMotion() + const percent = Math.round(progress * 100) + const label = stage === 'loading' ? t('expenses.scan.loading') : t('expenses.scan.reading') + return ( +
    +
    + + {!reduced && ( + + )} +
    +
    + +
    + {label} + {stage === 'loading' ? ( + {t('expenses.scan.loadingHint')} + ) : ( + + {t('expenses.scan.percent', { percent })} + + )} +
    +
    +
    + ) +} + +function Message({ url, children }: { url?: string; children: ReactNode }) { + return ( +
    + {url ? ( + + ) : ( + + 🧾 + + )} +

    + {children} +

    +
    + ) +} + +/** + * What the receipt says, set like a till roll: one box per part, so a misread date or a wrong + * total can be left out while the rest is used. + */ +function Review({ + scan, + choice, + formCurrency, + timeZone, + onChoice, + attach, + canAttach, + onAttach, +}: { + scan: ReceiptScan + choice: ScanChoice + formCurrency: string + timeZone: string + onChoice: (choice: ScanChoice) => void + attach: boolean + canAttach: boolean + onAttach: (attach: boolean) => void +}) { + const id = useId() + const currency = scan.currency ?? formCurrency + const itemsTotal = useMemo(() => scanItemsTotal(scan), [scan]) + const off = scan.totalCents !== null && scan.items.length > 0 && itemsTotal !== scan.totalCents + const toggle = (key: keyof ScanChoice) => (on: boolean | 'indeterminate') => + onChoice({ ...choice, [key]: on === true }) + + const row = (part: keyof ScanChoice, label: ReactNode, value?: ReactNode, strong = false) => ( +
    + + +
    + ) + + return ( +
    +
    + {scan.merchant && + row( + 'name', + {scan.merchant}, + )} + {scan.date && + row( + 'date', + t('expenses.scan.date'), + formatDate(scan.date, { timeZone, style: 'medium' }), + )} + {scan.items.length > 0 && ( +
    + {row('items', t('expenses.scan.items', { count: scan.items.length }))} +
      + {scan.items.map((item, i) => ( +
    • + + {item.quantity > 1 + ? t('expenses.scan.quantity', { + count: item.quantity, + name: item.description, + }) + : item.description} + + +
    • + ))} + {( + [ + ['tax', scan.taxCents], + ['tip', scan.tipCents], + ['discount', scan.discountCents], + ] as const + ).map( + ([key, cents]) => + cents !== null && ( +
    • + {t(`expenses.itemized.${key}`)} + +
    • + ), + )} +
    + {choice.items && ( +

    {t('expenses.scan.itemsHint')}

    + )} +
    + )} + {scan.totalCents !== null && ( +
    + {row( + 'total', + t('expenses.scan.total'), + , + true, + )} + {off && ( +

    + {t('expenses.scan.itemsOff', { + items: formatMoney(itemsTotal, currency), + total: formatMoney(scan.totalCents, currency), + })} +

    + )} +
    + )} + {scan.currency && scan.currency !== formCurrency && ( +

    + {t('expenses.scan.currency')} + {scan.currency} +

    + )} +
    + {canAttach && ( + onAttach(on === true)} + /> + )} +
    + ) +} diff --git a/apps/web/src/features/help/topics.ts b/apps/web/src/features/help/topics.ts index b27e987..155fd26 100644 --- a/apps/web/src/features/help/topics.ts +++ b/apps/web/src/features/help/topics.ts @@ -21,6 +21,7 @@ export const HELP_TOPICS = { recurring: '/expenses/recurring/#make-an-expense-repeat', recurringEdit: '/expenses/recurring/#see-and-manage-repeating-expenses', receipts: '/expenses/receipts/#who-can-see-a-receipt', + receiptScan: '/expenses/receipts/#scan-a-receipt', simplifiedDebts: '/balances/#simplified-debts', settleUp: '/balances/settle-up/#settle-up', paymentLinks: '/balances/settle-up/#pay-with-an-app', diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 896d833..048a21e 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -12,3 +12,6 @@ interface ImportMeta { /** Build day and short commit, e.g. "2026.11.13 (3305a99)". Defined in vite.config.ts. */ declare const __APP_VERSION__: string + +/** Where the receipt reader's files are served, e.g. "/ocr/7.0.0". Defined in ocr-assets.ts. */ +declare const __OCR_PATH__: string diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ce3d5b7..7cce0a1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,6 +12,7 @@ "src", "vite.config.ts", "app-version.ts", + "ocr-assets.ts", "route-preloads.ts", "prerender-landing.ts", "require-env.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9d71e8e..57bc68b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,7 @@ import react from '@vitejs/plugin-react' import { defineConfig, loadEnv } from 'vite' import { VitePWA } from 'vite-plugin-pwa' import { appVersion } from './app-version.ts' +import { ocrAssets } from './ocr-assets.ts' import { prerenderLanding } from './prerender-landing.ts' import { requireEnv } from './require-env.ts' import { routePreloads } from './route-preloads.ts' @@ -23,6 +24,7 @@ export default defineConfig(({ mode }) => ({ tailwindcss(), routePreloads(loadEnv(mode, envDir, 'VITE_').VITE_SUPABASE_URL), prerenderLanding(), + ocrAssets(), VitePWA({ registerType: 'autoUpdate', // Deferred so the registration script never blocks the first paint. @@ -39,7 +41,19 @@ export default defineConfig(({ mode }) => ({ 'assets/*-latin-opsz-*.woff2', 'assets/*-latin-400-*.woff2', ], - globIgnores: ['**/dev.kit-*.js'], + // The receipt reader is megabytes most people never use: it is cached the first time + // someone scans (below), not installed with the app. + globIgnores: ['**/dev.kit-*.js', 'ocr/**'], + runtimeCaching: [ + { + urlPattern: ({ url }) => url.pathname.startsWith('/ocr/'), + handler: 'CacheFirst', + options: { + cacheName: 'potluck-ocr', + expiration: { maxEntries: 12 }, + }, + }, + ], }, manifest: { id: '/', diff --git a/packages/ui/src/sheet.tsx b/packages/ui/src/sheet.tsx index 051743d..7aaa0e3 100644 --- a/packages/ui/src/sheet.tsx +++ b/packages/ui/src/sheet.tsx @@ -74,9 +74,10 @@ export interface SheetContentProps { closeLabel: string /** * `auto` (default) is a bottom sheet on phones and a centered dialog from - * md up. Force one with `sheet` or `dialog`. + * md up. Force one with `sheet` or `dialog`. `fullscreen` fills the screen on every size (a + * photo viewer). */ - variant?: 'auto' | 'sheet' | 'dialog' + variant?: 'auto' | 'sheet' | 'dialog' | 'fullscreen' /** Sticky footer for the primary action. */ footer?: ReactNode /** Max width on desktop. */ @@ -176,7 +177,11 @@ export function SheetContent({
    diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56efb93..5deeb0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@tanstack/react-router': specifier: ^1.170.46 version: 1.170.46(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tesseract.js-data/eng': + specifier: 1.0.0 + version: 1.0.0 clsx: specifier: 'catalog:' version: 2.1.1 @@ -185,6 +188,12 @@ importers: tailwind-merge: specifier: 'catalog:' version: 3.7.0 + tesseract.js: + specifier: 7.0.0 + version: 7.0.0 + tesseract.js-core: + specifier: 7.0.0 + version: 7.0.0 uqr: specifier: 0.1.2 version: 0.1.2 @@ -2357,6 +2366,9 @@ packages: resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} + '@tesseract.js-data/eng@1.0.0': + resolution: {integrity: sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -2676,6 +2688,9 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bmp-js@0.1.0: + resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + brace-expansion@2.1.7: resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} @@ -3155,6 +3170,9 @@ packages: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -3284,6 +3302,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3542,6 +3563,15 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.63: resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} engines: {node: '>=18'} @@ -3562,6 +3592,10 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + opencollective-postinstall@2.0.3: + resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3788,6 +3822,9 @@ packages: regenerate@1.5.0: resolution: {integrity: sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -4012,6 +4049,12 @@ packages: engines: {node: '>=10'} hasBin: true + tesseract.js-core@7.0.0: + resolution: {integrity: sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==} + + tesseract.js@7.0.0: + resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==} + tinybench@6.2.3: resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} engines: {node: '>=20.0.0'} @@ -4039,6 +4082,9 @@ packages: resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@7.0.0: resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} engines: {node: ^22.14.0 || >=24.0.0} @@ -4314,6 +4360,12 @@ packages: resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + wasm-feature-detect@1.10.1: + resolution: {integrity: sha512-+wz9+WpMKgQ7H1tm41nJbFwAvwX4DqY0jNY8Blw5Qe6hHg1yhCJ+AqU0R4lgX7p3pyo8irYIl1+mMNoBwSj2Zg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -4329,6 +4381,9 @@ packages: resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4455,6 +4510,9 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -6275,6 +6333,8 @@ snapshots: '@tanstack/virtual-file-routes@1.162.0': {} + '@tesseract.js-data/eng@1.0.0': {} + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -6609,6 +6669,8 @@ snapshots: blake3-wasm@2.1.5: {} + bmp-js@0.1.0: {} + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 @@ -7181,6 +7243,8 @@ snapshots: iceberg-js@0.8.1: {} + idb-keyval@6.3.0: {} + idb@7.1.1: {} ignore@5.3.2: {} @@ -7306,6 +7370,8 @@ snapshots: dependencies: which-typed-array: 1.1.24 + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -7529,6 +7595,10 @@ snapshots: natural-compare@1.4.0: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.63: {} object-inspect@1.13.4: {} @@ -7546,6 +7616,8 @@ snapshots: obug@3.0.0: {} + opencollective-postinstall@2.0.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7701,6 +7773,8 @@ snapshots: regenerate@1.5.0: {} + regenerator-runtime@0.13.11: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.9 @@ -8034,6 +8108,22 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + tesseract.js-core@7.0.0: {} + + tesseract.js@7.0.0: + dependencies: + bmp-js: 0.1.0 + idb-keyval: 6.3.0 + is-url: 1.2.4 + node-fetch: 2.7.0 + opencollective-postinstall: 2.0.3 + regenerator-runtime: 0.13.11 + tesseract.js-core: 7.0.0 + wasm-feature-detect: 1.10.1 + zlibjs: 0.3.1 + transitivePeerDependencies: + - encoding + tinybench@6.2.3: {} tinyexec@1.3.1: {} @@ -8055,6 +8145,8 @@ snapshots: dependencies: tldts: 7.4.19 + tr46@0.0.3: {} + tr46@7.0.0: dependencies: punycode: 2.3.1 @@ -8258,6 +8350,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wasm-feature-detect@1.10.1: {} + + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -8272,6 +8368,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8483,6 +8584,8 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zlibjs@0.3.1: {} + zod-validation-error@4.0.2(zod@4.6.7): dependencies: zod: 4.6.7 From c48822bbd7cf31d825c9de64691b8bd009d603ed Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:32:51 -0500 Subject: [PATCH 06/29] receipts e2e on phone and desktop, and the attachments pgTAP plan 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> --- apps/web/e2e/fixtures/receipt.png | Bin 0 -> 27115 bytes apps/web/e2e/receipts.spec.ts | 99 ++++++++++++++++++ .../src/features/expenses/receipts/image.ts | 7 +- .../tests/29_expense_attachments.test.sql | 2 +- 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 apps/web/e2e/fixtures/receipt.png create mode 100644 apps/web/e2e/receipts.spec.ts diff --git a/apps/web/e2e/fixtures/receipt.png b/apps/web/e2e/fixtures/receipt.png new file mode 100644 index 0000000000000000000000000000000000000000..9bf0ddcabc8d0129f7e8d9a9e8dc9b6d2e64162f GIT binary patch literal 27115 zcmd?RWl&q~-!2-UEfgtU3KVFOqQ$MaLvi=u4uK-UrGgYFP~4$71b2tx#fyaC6fXp~ z06|XL=lQ>T_RRTo=FEQInf-xbW_`%I*UGxrb^R_$xVox59ySFw006*yt01EZ0HEum z{z|aWQAZAk`UC&~0>E1tNp0_py(OT6#r-3mi?a}` z+nNSEdg92*_ZnoOAEbLGHTQxs`ujDN)qnTlJ`Sl7WHK@ut`p>xu8}n%c^Cpb>-)yx zOZYnxIGVNoMvR6=lP7%~3FaxJ9{)799U!K*eFHmO>10MNL-SUE1of|rAQ%9U;C=8C zwIn=AEC4`8ngF%RiT}F}FB)YFF4&Ki%C}|Dx(G+;=B2?22E~GyBz2f0G0I zTkieM-EBI0@zYEex}eJ7q0YB9;$j&ITkNDM&}c!9s}-l&Swl*aqWdp`+Dq=q+Oo7u z$=T_?o?I>T)W>B0y91wk5GR_IHKDLQ+a!mXz5x10y?Q6}atM%&feUnNctE@*rsLL$ zgX`zg>42Su)4I};dQHgMu703rydU9~l)oz)inx?T(+;M1sm($CEC|T;Ffw>ax>708 zpiXJLRnJgf@V2N}t>_zsU?Ef80DW(+(`|&F`DR*;*!W?zW?H#_c5zi2pH2m<>1!kX zQU{nUuyq;t%?az8h81xY-ZKHqd z;}3QpZfX{~Yj)6$xO}Qe3XybXo_G)S`_As+4*pgDB%kc@sQmzjld!%Y?1p0tZ`u5S zucbR~ed}f$@_}`QIBTa*+m&(8NG!)xxMA3B#-HETRbH#|qAEKjAhRN2arrLokTB5{ z2D;O<|8s9x$|B!}6;>T>>3wKQA|Z<$b5*~=uKnfC!Go>l}q`e(h|}7$ks28o&2G5d_YX}*vUgY zRRjM~4xGc7qCK}^~x|+UwAm{XZu&fl?1*IboD6r$QyyT!GCkFTB}K_a|13Hi`I##Y^EMf8 zHxUkabOhc6E<-{f6TKJ>vK5~cXtzLLsrXZ89lA1Cxc%-OBztd}b>M>T6FP(?fpP=> zDc$)cEqj%1A$YN0!Y~>dA>lN8*^2FjXmm0%78Q7>Zmu&x-Usn_5%;545i|rVT>izT zBwwFMVnIL4G}&mR%FUVVOBErFOA7dRh7x}63*7>czuB|{U%b>h67JXGm;xq(dg6ud z%E^r{9ex@95LvI4*G{~lD{;NN!l3B89u4|ka}UdJJHgcM4B=aA_)+yYhsF@_5%)$gH=AgP9C#^6few z>fN@9DXg@>p=7N)LBQqk5)#x7mY%z$K;u4&4Yb0)**fR9x=+IrIvNXUq+gJJB%uEC zLe!HEY-nQ0Ej%x6yfoMv?m%(Tp> zvzb>n>kW|f53F{{PuQKZZ-z${XXo{6C?1jIDzm)uWUOn39r(QH8#r1G)R5qR@KQTZ zu;qokvZQS8uH(kO#%G2 zSH_^A*y&4GK`(#h+1M6p{SwLib0_n5O=7$!_>+FA@7`c&OV{IU27IzSbzBJq-uU^Q ziw1x`!V|~wfEwU|DlXI)C577Eq535oq4}+1dZGfA!1C>(@~@x?`2nT8~Z$_Kv1!(}-P!}!hoV6P9jfPk{yNBTsLFkf; zu&|KOO3NI#rqfLd^?L;}JAb8+C246^SVlsxDsSP z#&&deeSkXc&sR>$YKB$=sTB0YY4yir-@lk<@taFY4O(6TI+*~d^C>wQ57lu8?gmuU zlpTigVC=>pGx6k=*9w5chi37&pXeE0j0BC6*k${+uFRt^O6`#d`hGC;4fLqTU<*mB z^xJvee50-M(_=J<>v8c`^ZnDAkT(fa#M!g+&gq78DMCLp?}4_ReVLhju`RGqx``B@ z7k-c)v-;rD{33h!zEw@)JSEcEmP5cW&TQ82gim-TMofO(9Sm#;IwT7DXWIuta1vT1VJ`O z5F%08%`l1#H}qt)_43R8d90!Qp0>ee&gBv`(B3_a;SXk4mX_CI>SLA+DcR2#mGKQe*$#_KYkldv=I zP9|tKQ1bL8Rt~>wwlxGoeEfMGD8%v50XcIu`g(oqyQ2<{=J>0dyoLaXSgu&+M@VXw z%hd+|{w#Z*p_&M1MdmeO9^l(C>M|oQ$LcmQkyR?Mh!cIbC$>G{CBEnH{A$z|v##@i z6{#!B$R9RinU10o(1LcyJF<8S6wwMdKLo|jd})F_e;ti%_Wjmd<@&BKQ^nX?jS=w8 z21}}`)5>gSZ9A=CT1n}Zc`CwYzA|)$vhQK#;Q1@|w6ShAD>FqAvz?J0&~q~bc1C*0p0pWr(!_*%3^#qYa1h*yPOomRJ+3l1WM{OO`-y^+v23-PTxY8Da7 z68n<*3!HDyAG};tx;*X07Jz;OUBadCb@DFnJnlb>6BpayLG_It4G_i$XHTjFPicg> z0!hQ%ptHu~9j0B2^ZhiXvuF61gRFeS`EF~#`{>*TWl~=16l#OF zhxrnqa~mP9@t_I{ZoEcY_xYWSU00A2wB?^~o7#=>$&)gu@pP{j8$=+r#19*}>^A!0 z`iB(_fE*t$K*8--a(BCdR~ZNCR@YP1 zH$x8;;fPY{7QeV>{2b-k#<7eIJ!VVy#7MW8pVdMe=8_jOYF= z1Wn`t3Rquzt$iEmC?nJ4QwW8HnRE`HohT?9Er7S*?xg=m3()>aot~>?(x-*@GJCQ0 z%`#^x1L$ge1;}%2zm%ERfr*0?KIm63u%fkwjDCQ=!;e0-rKfV-x)Hxz;PE-k|AQSB z>SUkqa{&+{u-b<5^R^85K?ELr#iEVa%)pQ zWm4V56`||=dL>M<228SIE0O2(_>Vlj&_9@68;XW6JuX-AEDW3)XSla+!QkUF!z=3}elPXG9xVfJwe*K=r^(EUNqUy& zeskT~1#+0Q6E)52*GLcK9?FJ*S(C>Q{g4|&M+%lsnnMkfVHNiTq z4F!tdr+td?Q6yLsBFgL62P!y64ql`@2Z8FFPdnjzjZL++ui_@O5M{dO(XAtD>4t-P zR3vYkeZ21a#*0!PlMQwaX0k$F28oZ4lH`nS`3j@h)#_rsj4B4a!S$e< zYhHN^+MvyY!1-~Uz0w(^1wfE_;@cN}4AFZ@xPhPa_=_z^uudq7BJxr5U$=Afz z_-zvT&l>#jo!L7Q%bkZ9gMb01xn!ekbFYY)AB|i@Wp4{KlBFBFgTp!M0pm}!N+gB- zM)=IjFd{PcEoY=Ff0zMJY8+W+N9L1S4g=8=!U{D2fEN@F|9zU7NZXH=hP=GI({f>{ z`r?@tDS$U8mXx>M+m*dAf?F5V2w24DUU=R}0H7xL_?2YP+dF89U+l~f-Xw2ex}fRP z9oYc9h#lc%dR?8S@SSUU<*JNs)4=w=iH_i{CTVZX%xh@~8C3=uixU#vF>KSp@yCZwf^;`%?KAd^10MeXT8xC*ln zVx|%0Z6Q0Zq-@lggbcpPraX_j&1lCnATUYwzgT`EG^<^t!wW7UeVg`jH>ce5GGY75#``^dR~EtT@pswKde z&^`JAIH=wF^b5GqOtdrVv*l1T+1tUx^J9 z2Yft=VStWvK2%9p;6m+=yTzIzYG>v^4Mc8axzl519^a1BohtWO){;jZ&z&hYORx=E z{ojFHbVkMQdoG%*K7V=OS*e9TAKLDbmuCEfogtr0`0Pnzfl13Up7xZBjy+C{SWt@< zO^F_UBO|icDXRKk9!i}i4D)^O@I4UP!u|*-J7WV8`vb?3qgP!O(~Nb)j0k6!9c;JT z^VGB;$|5-HUPV2gi_>_mpIne>|7Z^f#-4D6-ct;1%RaBd*W$BbAnw%S5n=e7lOtOR zg1hG4>(R)Cg&>Z=fOMKEkGHb;%_P}V$`nl7w%(K-s-o2Vlk_toH_wPDlgpg;cjnjM zY~K4TLJ~-Mts90D5G4rh#cr7lI3{JF>2w@3EWPQ4AP!luvkFpYz2(^(9jlOAGnbC- z4;04^Hy{}`X(yGMQM0i_T#{X~rXI=@0(=Y+acq9)DUnyc3-hlLmXYy-^jSWN~Lr!T=Dc9 zza2{yWw{MbNE%-YwnwX82K({%OC}gZk$GeC(H?4PxtWhV=g1a*Z!KNqogW7}f9$S> zN!JR$=DapVaoK?6oZ*kjacJ8@Wct-p!*ZbA5)kbY12bp2?(?69r>F~mEo-yX)*Oh*nC z4DEjcJDK|r3YEkBm@ z@bGn7&5A;XHk2QBXI-@irN(C5>+S)f1Yb=`n-+hlw^oVj?63(OKDs0b{`roSQDr*P zxmq)TiO)P5J3w8B<YIPHwVxcd&ppNIB_uhZo4_)wQJ^ZrHfqraE(Hk&~!o{1~wr?|y217&;2 z@%K6iLfS^RX)^Gg{zUZA#X8eNDtGCCe zw3%-QqLNAkTS{_^{b+61er+0iT@N{+OpUp16Wr$EI#L1DIg>u1ZXZO%o)%N@LY6(R zud>h5xV*v)VCM!K#dC|*56apYrxSTgc0+PoXoY(JwQCl}amLxR#HY0B`Nf~ajXKxw z>6ODBk`<=1={S9GDuO1hpE)K@+;?|u#+M!{^9`68Dv1f2xZ3<$>p|dk2S1^?%V+u+^70_ ztC~Pdn~Gl?d}nJWMn7_qku9}}4`L67uTaGKYUl6H%LSji#-A};n@*tFS9WGWCAzb! zD?8aW+XEW!vz$22fT~6E!wR^e>=5P$4yB=tZjPwkKOjLMg`va4@KQecgX74TH{#8w zENZSRY-D-`I6p4=)B;$2sa!Um78bg7j+~J_o>syfNUZz~Tn~iSu?xYcug^9ep2Bb- zLW9F+y+cXZGIHE;Jce>+`dKBNl&V5L-oxv_6e$@WPeejZZWj>*Hlp_vObCIOpa__4+5cuu$$|{6N4P;3O(feMi!SH$}th z<1lqsuemdir2T~8L~gXWMA$I*o1IA9%*J*u)!s(EWzZdhgUV*_oJqaDp#+|s>tu^*K?X^u#WM=;O2fCw#S3kE z4+$yTON>|&zSlYVT)mnW|5+FPHPU5I77wX&+L9tCc_*35tcV;BT|BDXS_ze4`xQ`X z_xx&i8t_qG&0WE68t=>LGAbIs{nXXq zk$t?-AksGw1yx36$4ebZk@*kM~3|NNmG9=+HF99M%i#gO3FZ?3E zI~39L8vQ-=ls8HWLxJyA;TvVE*^EjH#coTx+t&}?4}AP@=hXi*tZxXT1KNMIfd2+V zzMO#^|FCkQKg`6Dif}iuJ6nf}9OB1eGTJZuk9@kvk_G-NpKkQ9LD}xFZjl1#&*F;s z_f1)Ct(RYZEs2^5A-7EaiVnE!nb;va@tFzeSo(ENg~%8Q^cQ3n-z#5VaaA@WXo%J+i47J;n2&IFP{6pJiF2SZY71jbuUQHcSv;FW5u+k@ijUz0d|D3 z>M!J=%m=l~4@)pfz(ESzWeSbH@7$GI-^_9<%x-_wdTQFHp~N?r_t5;WkNnv?teG*i zy|LN3q9$y2Z)%$k8}JJsMR$M8kU(E);_4^2i8q#U?`EwDPbJap_3)=E%kY`ZHvib% z`;MBa^V%|fX4z3zt&vXc_{1zpJjyoE8e@xwG33?}`TcZ|Ru0MKZ!d023i$W;Xd~&;17SBa} zZ|XMCYuo!pV}Nv9ME9+PLUKP2;5equ^tY6kP z?K_)UULD})eUuF$0EwEsIaho=&mVh^{h?1{R{xM+d$vjVW(da+n4%*1Nv0g!6mn(A zixq$T?)!xne(rVjd8J+OXKkgKb^hx&tM%3rp?+d z)bB6nd)iqVRI=rm`Qj{QgZHsm(4271$tU4p^!R`;qg`uReQYiEtJw=gm5JV;f_9@K z%NvGqDU}%ypk7G8lzJsEevPU6w5ZV9cA!Nr8cK9mDZJkm#ok(@}QzGq!BUlJE_Q4^ua&aa_8po_Bf?eep9?zun{)E+rnu*y!^-?6^tz^~*FUGNs?yzvpD%KB)2 zIC#+}$FBTZ{%?MO5%a>MoPRaVs51BMw~?plfI-^maJA&c^*{7b_#vM+w&XLzE95!G zdH7r9<9+$;16 zp#}l~R+6x*x{c{fUPiG7{DFDb+2aOsuBE$_qnHH>@lO`s>sAI@TSe{xLzxacyaE1| zk$gQOpE5Y4cTBf3;VPJwdNkbaft!>Atf;4#fOR+PN2;Lr8(1d0S<5zi?Rfh$H}UJe z;3QZJU$-{Tnt35P*Q(p2VmZfKn+Loe06^!6LP8BuSE7Wp+>EQJWUNw_T$%S5#~LO4 zqe^$wRy!}$xZ0o)Nd78>BjA^jpC4IzwEyVh$|UB|O|o@TZi3QwW8!nw+**79^%n^c zl=s`$*bDn6gQeEH+?nR8@qy7ebk7H*{m5P3YeU+Jm4|mP_6Ae6C*v{2-6RAB z{A_YbqHJX0de%ZwtvyxmLBL)6xM<2UcYb2CDxXLA|R9G&TIG#_HDrp_3# z#fMFtMRo$ihaw+)m_89W_ox@%qn=cKNUYN#%!*3?WTq?oQq2c-WI4Xu$5xd%CV3>X#N$f1}j0+eU`(2S! z+AdiwyF)!GMaBba;k5bYpRAd-uD_+5V7S;T*q(Woh?Ig#3LTCYI3NFOUak>SY*FxR zQ`*$C1!&ap$0~!WTy8|}eUGvx~f;=6M)wz-BuNqK!qV9A*`qL4odm)v0sUzNkHrRku4N9owb9wbvp; zs2N!GWxB~qr*NpAzW`ivqN5m+R=;jp86c?jtbxn=45d)iyPdW<7(v3R%k9s`t9X`= z*}+Tue_4+=3rd5U-^cWv!>nO{jPg*`CbXJ*4V4t-5TJPhcF)CPnIgY4b2R`!op>6n zl*rTWJ`E?a=~AI=JDZjeul%>Vr0)EIZ{2}NVLgZ?0(Cv=R4_o7jq=Xa&bZ09@i)J z*+&2F=Ol-mh~BE?!*r2`VI}~;TiJZu;%fl!(dN4;CKp@khG9(Dg)VgORNN`MC&~aa zm$hM*`Ulnn5v$(s0~r1nu47+YTnRIj!hMxPG5&$^f9=VNp9_@Y;DzN2FPo=+vAc9n zvW2dNh3eqHPanK5TAf}uZ>>S$cRMG7wAyuWXy&j&G;2zZ4eIflJoq@%_0^keC0s95 zNY0D6PmQtX$^P&%^Ef%uaBbwYqSamGs}{VXT8o?9HO5oykSg``0S6DfJrejx)Sa$< zTzEw&`qJ^j!^ErO#?fJOz<{k*pB5^=h$NfRC2%`<@fhvf%LDdQ^Hh$SJZ=~w5AS&* zxhP)sl0R*GM{%`I#Z+AZb+1&>#C5Vmhc0gSXJmZ#+h`uorg&{jO-?n|$;=MT+b^o> zRmvVghEQQLmYd?>pqWg(bVDJAb$NaeV%oWEgYvJ-xE8*bXAhN0?R%DNizNlH3m#UZ z8>v!0$VewoyjmSCJHUGNx^2%~~ugQRxktk+00BW}a-yEm}3#6$9R8zE&ad z-vy{i8}W+;3iI=GzjKFveG9F9vwx;>7bj1xmTOnCkX~x(+k6&bjs0Z`?L8(jD)hKn zAA!~>?jY4Zd40S44)k>=n)E_Bu+=P7ISLxjw>mx*cz~ zHhH}=c8B{EnS=`;kjO6Z#-3lI}?)Q8VL%p6<02S{r=hk`qg9uco9FHBD5(!XYvdlABj4{NmqT(;j0Er zW)$2qF_jj4{F*MN(c>S!8WnG?A}#&Zm0YngV!3(w$aH@T8m8Q770Bb&8W>fMa3!in zF~LKFtD^&z!{KeJ%)EloSpa}nBIZg|FD@&Diz25@w~SB`K@c7w=N10dDi>|-qMT<)I7J8_tr$A`?0|BGrgqnU{I7kWX`b0 zUR&_;JiBdzD@JBAu_BEndCY}%wN)mnYFO}oAJ!*c;>x|fJs{prsWgMeyNvPuQ>0AO zO*|MHgvVi@9ST})SvV&nH8RhRNoUB}BZ8rxo>hKj>mOjD0|fCkr(36EQy7(D%W55u z?U7rnI$M&bJ<6u+q@wn>s^uia%^5@oIK+vh=}Z)Su@39BX5Mpm4G<=>#|Yg7lQ+4Z zaBUaez(4f|)Uqy?`FzSLB~;?0`dIb+3VDTgusUb#vTbDa;<=idhDv{4?9}%bP)6&b z^74{ubVhbcN6+j*uc-@2xF|NGfVN1u0YAH$r(WI}AMn12^|#05!?z=+2j}X}6A3KV zi?xRPj1?&p%;nZ$?GRn=J!&158?$V^@s!O!@1(m^ zB*JAkwii)^TuEq~iIhNWzSA1lGJnzwqF##;P!>Bh^+|coo{b; zvBjIzTfr$Iif4zD&?$5O2X<2w}e>uETFk{evlT6ea*`aV}QQ@HZb*`)p>Ghb;RIS4( zku;fTr|gZi--ASCpe#nIpMoTyIMF|lg9lg@JZ&GaZXxHiI6{e=&A{#5s8n9uP>==z zqddT$cr2G$$qr=>Xgc8%0(r?>95#>)n>`!SC%U8;BZpjiam>%|QAS@l3C1IM9Af)w zbic2VtH^i7@=l>r#q5asezA&jnp?xjlR0(X3_-nU?9XFC@e(jCDbnQW?cP?eZOqU7 zsI1hZOGdyskw$}qkKG`#WUWNS9>}eW%YaH`T+TYu=;jaC@yA+t?;xRlhRwCtsO!!h zaQcI^-X71`P`La5DNldu;z;x#E#UvpB>pN5D|MBJ=Z5+lS`xs`9BRnNKhG9hQwJxU zD#NjojRpS$&My0Z!TBvjR>XopE+CIXk!C7z!G8F8ei8G=MK2TJg+eda@lKpCJqoMe zi+k_Xo*W#+zPUHpi}rK;80bO;8ImHhe7Cj~OWOv&U`HSXir0{*zd$1jqTD`$v*ycR{k#%O4Y9;uq3b%w? zG-_N(NBXJ!iwsz^KBtYa(MJ@7f( zM*@b0@>O-%V!t#o)@ijev=GW-TtrpIi*~n-S0w$oxwXh)JS_Pgy*Vn3A4F3(QdM2g z&om~cv4qV~SZ+ET@K791sR=XbHSa6nLT)La6-jgUFl4w zMbB8ECag7gZ7GMj*aFD8752h+nw42Dlzr3dK5=~Am$51fz?ug|eSsslqP&#;ARU&6_Q_dZmDld_c*%!IvWwjggT zuNHJ+hKi_hVy%DJiA_BFiOQhtiZ&^pcP=kypk{c)cg^X!4A1M&iZSGaXHt`qzjb3= zQY7hz_OyB^^%@)XN-_4bb8h#3l;+ukY@R3eR$ZT_0Ov-bu46-!a7A;ZSS_oQ*0ZFB z$%T{!r5nqDqF-S>Q7+8y+`(08V>82v=O5*Q7x5A%D7~}9o*7-BX?%))38;e{ZHJRK zoX0v)-WmQ(fyz`bJ%)OJexfaxgW zbwWLJs=6PsbKsv)ec{l3J5hU1Ee)`H+)RvVePMlz;kBa_wetH_X^>h2*>870*XxZ` zu}1M5XN!e0B6i#Nz8|wB2quAMB}$`Jw>rr=XNbUU-<}|iG(8%r9c#FguNY1~<{br? zA<#l9Gghga1@4tdpSQ;}HFBip$+j(cc$r>nR)Cj4*&ne*$bbF}{d>>n7Dl&MPfW(Z zMfb=WX~U*JwiQiBY3IOA7OO4ysJ)sZ9Mc>XS4teTEvQ(Y!j&=~G&BxxF;qz*WE(fV z<*PqgXAAl=3@q!_pS0@3b8^W;>5U2G;hMU%O_Ra7TI}mBTI|wS7&y%*jBbNdRU|R4 zzzEt^Ix?+$Sy8SycJfU%D*MgJtL53A=qA#)J5OD=8XH<`)$FgmS86+J(@(dA=rNLD zbXnTJ21EC8c;vt>>+Iw){r*V@_~ME#W_IGr_TEO`bjh;!kJEf{(SC6~p4J`h!KaV< z%bYen`xTJ9w7Xx}bN@JA!=hJoKHXw8=^M1xFuG(ZwTc+^iYeEDH)rM>Ir0cE@+QIr zuZoFds;p!6&jaCdw)5P7`SbbeN0wzsW0^jxsBQ87Q3?MylUf#X<$7rdRCeUz*Y00<-1YYLuttX(HSNzA=4mSCIP4 zy-Y{r&ba+fWOcd<#vitDgr#5CF0y{l{~Oop0rnT9C33kFaOZLOTtotM1i|eBcTt44 z99`<9A?L^o5x=?Qs=Us|Yo)OwJ+Dej@7drtEdcfGzj+w88#Um`M{Q<8`5>>tOZ7hl z?y{2_W#Oo(>9IxKV+xEbI1L{|xv z-D@QUE<69u>5D9-vd11gzV-!0ZY?PxqNsO~^OLrV z)r`c)Rt}&3hSke|easL+GW^7=k%kO87>5z{B1tn>RBO1b%de)~iJk{&Z0|C(PN zJ)Y5Y^~rfw#hqL?pPrCR(W;sZ8W<=+uY28|@DC+SE99%NS)?FadW0f`^lI1q%Bare zxzzuK70dq_Cfi?QO`=rc-%0>g8CmA-db9++fqBkv6j5vFJbl=xJy5F({aJ?ORprzE zFWm~VP;INTuXlR0jW}G<#!-+%h-N>{6z&?9*>(A#kW)Q2?nq1AP9rr`u-?waeCu@c zgZ~UMWYATE(ngmXG*o2+@*v?$GF_$EaD>bCQezP5>m&prcl|^Qr{x~emMGPqDN73) z_ctA<-4&R5t!ty_pEEp5)tDv* z5j<6$M(ht$WB$vf>-?~-e*C>`_o}qBFhi34;T);Y?_1xBnmw74{?CcCtv_vT!>4%e zFcN*hUgC0S67C^X(hL}{2{R_6F}<;i6jDR%xW-!p7nq^+AjSAl#)qk*>q?UI_3+W+ zRt*MoQEG7%Cv)AOj@gYcDVT#W1?qllD16dsq0Yz@sAJCOc#YCODb8Tr8=uvq2t4uI zsa=yq{94Z1{B$a)R;gtQE)M2*7PkXF+)G%SK+nUO0Ct6+*O#7qNwKOgG>*AAlM7&) zl&0N|rya%2N8E|ady4GTnx&ebGd51|k3zdbCA-Cf+AU7`S{}w#TrQnldbz|EO;Vuz z#KY7-eqzsoo(=Mkm-s3Do#WAON$)zrgKY>SsxjIHRk@BFxdFIg`E3O){TX8RvKL{; z_%NFnGcI>MH<|a>l~>dnWVN&Vz~@XyQ>!{=jc{^mK`?Yau{HB4_j%T!FU+?HtO^dp;mtx^>b8Pwds!rg-Yph04Z*; z9+V6aqo$%#noGAP`-CF?>dd*0e`lgl?Z>;vvy#sqKb&o|`=@{!vWVcY3Ir_ZK8E6! zWRDgm;uW`=6)y{|#=a&#h={=)SlQzHvu^l8UzfuLy^GM#rrP?dQ=SEdT;BA#dyLkG zKKc7_Gzo@qL6{%+&9eS%H3up_ce+yh&U}vk@9<~hzkVogRbQWI5Ad}sq8>6Dk^7ND*uT6MX}3=?4`YS1|U+#=rG zL@3^~ypK3p+f7_d8N3v}&n?nW*+8l?BIBo0#RGZan)pimG3XL-b&Aw9s)D+4y9m=S z#>=ORA}DKIzmBGx079I3I)bN$=?^J_g>-vABWVK7BQSyYHB zO>^E+^(S@utuZh=neCG7C;I|Rzx&B3@ZQ&nFsXYx^#AIy$|*x%qWZC~(T1)bs+1h7 z19?uJG&OR-rqaF)@nS`+V9mKa%$bHpfZ}|qX zhcPR>^R;cZ<0Q9d&3*XWi@iTgmpHP$P@}h%{Y8dS^VgxPSE%mcNO4>wpGtEo`^n(6 z8R>x)`_wE4QSvQX!*ENJ22ZtJT|d~`x)pOJy*at}QH4+4nFuz(&fu6T5@qm~&T9Or zg{U<*mX2=a=6F2bR=}uzE0jX93w!mi2unwa&yTwpL44=BKzL44FGZ8%?{>K5-BXrC zayGgIEZSNGpFo>v6zw(-VQPnEugLYSS}_+wZE;gCL9ld{66uI&srN+;qWS84306F6SR-UvWV2KodHam=Bqy2Y%%f6@OLp+0fVvSYLbya$2uU>A?5RQQ{)AF zZDpbKaA)Tf)#RW#$ALRXvlDJu0=<-7+*!NEmN?RidRhZ(*iNj5kr`!oE zT7ys=+GLRebCG_&4ydqpr*q8n;MjBROzGA!y-94ptxo?9(NQKZC->+B(U@|7x2SX_ z{c!`8R>N@y^B??1HL>R&6aCSkoCM43>;7IF8t_SSSRcb$hkn>kBskhQ2kwJj-vE!@vult zBz?~}etmhurDsc_#Yq#&f=SyryPIepL zr>Y2wb^*7Tg20Fwo_lagYf{f#(egoKgM=@Rmex~Yzo}$XVq6z?$KAAbEndf5{p`7s zJ0h+a^luCW^U}3si3Wawb@j)-I1DE}4twiGV{F6Xn3-n8JKVpUj zjUDrkdmCR;wwK^J%05Z8?4WCGSD`%ipw`X9?dEWcF6P%j{aOa|S<$rAmVnNIRXC5@($2W5jSf3w9!A)!ZQD72E5x<{7-W7!})hr|xO}Fa+z7ay&FpbQ^HI^*U zrw~DkYHgnduUn_Un2LY0glj>*e`_)p7_9f0GN_PY;YWmF{+!``f7{5p6^`at#nBew z<1|I%hy-UN@td@%I_gqy%EIR!N#5J1Ys^IY%;|Qpiu1KD7Ps|_aF}t8q!g-Dyxpqp z2Zf`&JfB>)0C@;^cH%7XX184)8(qQ?ixk&rYGgK%^l5Brqw0{Fcv;-P61leOschP8 z(+=O!@}!*Ak#l43n~U{FIUbx~S36XFclW!aK(Tf19@P9HZC@2?D$+XftwFWb2chFS zj$_(5ACy5Rw?1HvpAGDgU4G>e?Bs?qj);RGX6Vr(7Tuqp>)2TN9wKdH5t_POnemzz zRVx+kGaKZK53ORK6eDTP}-L72Bn zg&viiN?qWlV%JXbdWq7}=eWo~itxB9%iYU*PNP>`}c@)pv4 zE}d4SbD)k7030zHcG{!l+h1revCshT?NBZus)+NE008(L`r-kqef)p?u=jji?0d_$ z&Qk+Sxa_|L8hqLy>v4#&9!QCA_#bsP?vFa#x$8B&HxL#-Io?XIJ*bI9L#HCM9yhuFVQdMaKMb<<9?<|F&fWNCqas6;N} z|0O^dUq;w};Wd7lg&*J<>O+QAzFqXyp9NJ@^%%Bowb`=VB1Hws zcP}o;=4H{o^-%@fc?^naR#H2wU4+kRmi}82Ny_l5I0rxp{x^#r#@B>mTQ=GE=E&#H zsOZZ@FXnJ?X7-#eTEX1NtF;PH_4NN!-gyQ!xwiX0qM(RM2L+`U=}Hj=0)hh4d+!3F z_YP7N5iuZ&^e#0-481q0ic+M64ncZHYNYQw=vwc3_u1#eKC?fZnf;AnhCD+i&vReb zzx*N@qvGfnI=scQWY60NBOwmA6R^^HQDB!yWnDx7L9md{i+F$vGtqh3FFS0gD=t*8 zRWCa)Z4};6BwKzaUhoFYq51YlYe1BDXi-&CX~NfycXM%JA38LNCAKqOH7ll2uA-LwC_Z1pEUp=Zrcsz{|z460X|P2V;wMS$^5Nz68>DAbt_krpXj`Nyc(f zSWYNMlTz8Hm*Pp%e=)fwY{AED&A4MfUJ8&-x(N~U?{F_t6pj02F`%m2XimVG^62Dy zt^oC{-|Wo0yr;LN*`dR0jab+kYtd6XI-YxxOj`+~w9UhBGaQ;}5tr~j(F1;rnkBnZ zpVhB#PzudW%_Nz3Po>NK3LU)L-Yl{?;)bUuT!U&~a1~d3qeI?FBIq&17!u^m(!Bhj z0Arc6BBy9@x4EE;{Bje*>TL~02X4TT9iT-S9fJABHx;%9Nr-+PXz^uZf!X^E#Fmm+ z@q@3#xjy_PP*oTrqjvBiONEiZPUn$L;mEnp*zeceTekosD>X_}%bBH*f%CRc!9rIS zL1fUp$U883XoY)+bBReem4g_Dy$b&YffBZ_Qx0@I@jOLU<$8D1@F0_f@BQ#=O0*<@ zhySN#kL3;oSJ37E>J7v`FIF%-QSZc_3%+GF&@#YNz*C-CXiX<=Y;!gm1=Wb8N}Eljf663FAZh z6RQ9_BsfLihpfw{+n8VL$%jYt*jv>rbptE5h(gMfCUqGE>nw8+%I1xGS&Jb^HSszGNpZZ3L?#~@w=bRN(vDP&$g>80 zo^Z+satJ+9@!gS7N+!o-E6{nX-9ADddZ^(7UHa*#C(?!($e)4ec0Z`?|3CV@|6?BS zN2Wgxu1BHqZ`g%JJ685O^0jNK%?&f8-TY}!( zh(hCSkn=U(xghM)OL;du9-;-s7{U!0Wl z)bA_iFD(GbIy|f+vx$4Fb&>W}?(_P@sw&DuRaY(OFF(0ACiqoDbl@LZbVprb*vC}U zh@W5Xtj(*dZfs2c8U?{&3u4rpWAu}nySkmJQcCA(25D(M17M>((J+Cem#5kc-kHfo zg_f%0lpol(ruXMv>%v4IND#>)*ysAEtCXQ{bg8#}Ql37Zj*9t6Y3wJK+du8=DtXl- z(6#9H&8xeP4RO>u1v71{4r>q$;EYAs22FU04D)GP9sladSAcR4gFRdon%(oM)RJRW zAaRPCISxI88D|yX-jepzoZAoBelKIGU55f9%j$KC#U1I+9M_Z_o*f1%5&xOjO}SLh z+tVXISaAWqShfr;;ER>WNrw#hU%nSAzn_fi=P*cW&v$YfG*HUZx5}GSg9I%R3fWP+ zcN40x(~gr1E=?if7kw68_~O!JWB}&H(0tfvrQ_t(A;vslQQTRshPwq@farLyKKJx4 zdn}4XA{HbIvt8ol$WQ5BYJ=7^p<$+hZsF1h7#03y4d2!hYMqAugVPQ79mjR_g@%J2w`x*pm}% z7m86DLrBJzNMEQ2yDFMsc0%~U!7c@Kwtn~;oD0;blhxAnhec#8PnCZz!W@h#kH=8hDI4~T`?qun$24Jk^ z>gr2lW&MwV^9g+f8h|>=3ZN^v%yW|Z%+L3Xa_#4#_%%@{cl$cJmg-CXCetv2l7yJ$HQ6G z^~Kvx%`NZkoQD-csiwiqfZMr=-8NSG|Loj;@BfdT+r>-nOi(W3YfTGftj+I$8C3Y< z!A3zZzT%&JSyjbA2@2u2fY$tJ3>O@o9t1IhnBp+cRee6Xd?sPXH1AH<(8e#9Xy>0$ zOBMJUkz$xsqFJAD4L{{M{MRZC(PY=WRj4f95SP%TBgH97ODeFFuW;JjbE;;>nU5J% zKYvNyL}#rFG^sqMnXbDUu3F;kjZ22+$A0MDh*pN$U+nT)h6!YG;*Fa?~3KeBxO8Zsuo^8fz?YW;5v8h0hH66=b< ziOrAIE{T@5I{DmQg7?A!Idu)Ou(KVI?6umya9nNqXeWdDq=L%WN*iGmIn%J$KhTX` z9ND=Emd#rm%t^aG*e}T^+@7DIF~1!u(au5%1D!tD2BuZBaw#Op+wAjqz?(Q8Bx~eY^-hoNu+~TvR7c*E{|KKbA+0A% z@)%70i+ zgD;+PH3w@@ohIGbFM!ekz!z+>@*)6Pp`)i=;?-t5bS}uh$0hKCPPdcO$#6e>vGpKU z#r4Wbkx^Sc>&fyxJwSpj*tK>U>qehteGw7NwN~L7b5ulWx1M;?>Te43)AQoZEwD_i zJTplsx_`JB<)38?9ZOvyugE>NjoFLlb1MuV zGKFcZbu}#fD4+|acBsia?~rpXGajkJLh;C%zWg`kt3ily%Y3w48)@+ZEa;v@e-?Do z&P<4E1)wX40lI?bcYqOF(>&oG%U`R1ac{?aD1bZ6Lo!WAfE2hvQrO?gxXjly4ugR+ zuOEidRru$=6MYGDqetIT*K>K`IXL8JN!yw=#t;big&DZdMbcNDlLgOsLCBy%)*s79 z`&}ULcnUkHht(364|+mvi;=!YJ$)pjl&pZckf<~(qGYfn$?d-X+(^!qh0xhwt=ug*jkOCs6c|KX@Hks0)MLle5o2>b2&n zyTAk+asyfg?dBkq_h8?E(F z)BPxV`GZEY9>YS;?Rbo^L#d6x;0}rYlz!&D&dfZziYzCW5IZV=r?m{aM>VPw!gud9 z?1IpGLxm3J<5=w;K#uz43MRmM_@BY8>F|U0v;Q7q%7i%zKJqifxrB^ZE=2j7vJSSQ z`GE`(6XG4dtWE(aAF^mB>hU#!q>`8>az+ZJ3y(9n&AJccBA)<*aA*bM)mM_${ls#Hjl%knUBEFJIw0eZW<*5tHJzc*9$ zdZ-^Nk8g?THk-ug&)rd+DvC7RvSw=Zth?ON;B+Xxc{3+>{V$AYcxts(8Pdy8#WYDj z;g}7lfPCw>1pWhv>e~``8oL<$Mi5j%1woD;U5GOn7!4SbQ}AQMW#gHYlIm@fx)5AP zdWsMUbEgao(O1F&Mw{5JvtzP~Hawy~SZbQr)X*)&yFNSNdy?||2fw-jKRu6%6hx+s zBH;2!BNid}&)$ePxgx0viX$R9{fgdfzBm9T^y{Tj4rr!o64Cm#c@@p$1r zG5L4?PfUC_O`#QLoC<=4zCRrvgbmN~5r_-K}@+~nIjLGT7x`x=J%9ZNpsbwql7$SERm;iDvsJEy*MCOK3ORvw#q@4^+>Pv z;)-)3ZFoZJTtX25NH1ulfCj=pB=LVQr2ao8s79Fr=OGY6UEKEw{Ez8J|9$X4X)^$vmJBM)IC?dwP=N7bJhY`@oV)kq~Ew zq#Ah{%|l)4_&4rt)xU8ISucr41HR|gZiPXV2TTFQJSfe_>gnYzoS!kJ)f$k$Vi9#~ zW(%WMYw(texC5Emuy6PET5WhXCb9C8@0?6^+6QjYdm9}M&0&-BL3%g3-{x)-i{Ix0 zlqPzq{#*?TG1l*$3)vLeSNjPs8msAce&r`BMx}HeWw6JXv&l7T#HF!S>T_fZ8tjR~ z^-_ARNj7R(Zb#fUV0tV6%9w0{U{5$dYDz1NAV0Xbg$8b_*J&o zZ=_8Gf1rg4IH9i84m{mvdofo!{nXntl&B#susp(bPG#-O*7eI5(?b|8oat@q>b6fx z>Fq%4c{;#=8I76%lc0*1~+R&5GE(}kK`wxy>S4 zA^y{KVpa*vqm^k2lC7VEr=7NFs&19Bu1ZzGjzR7SDRLB++4qzM|EKJI5 z@$-cr-dY(C=q7Ii=52Y5LrdKjpRXCd>?@%tm7Cpv8c>^QA*?DOI1?`iMb;1dFBel&0QuN6|QkA3HDNO*cnl2OfS5^To1Fmq=C34+DTBd6k3 zOkQ;P^7h%4%#!b{_&?7B?9ew-+j{;7+`I#Vv$nDzTT>Wa*ECgo=eMbIwiQHaq66uq zwQFEz5wuUDoRwyjxwt={aP%?A&zr`{Y?z*XR6QsB5#L-L{b8a#V=MGwauO#aS)4<% zL!5_8ChW1A;OnL|@RbGkS=69R7={fes!3%y**-lm=e z+{rBy>(4q@#4^4+qMgBB3^4VZI7~emo0tEhTCV)H(>D}v5hp4kAJc`nZz@!u;a_mo zl5hG^=1z}rc3%BSG+6B0uyH2>jT&&1yIR~CYo7x(DlN)mMqi?1{>sq6cEze5p0(b_kA8X7)Ekhg>VywMGf8mMopJOEXXg%<-NW3KB za)tcX&8tRRCW?4S0;wa{eG!Y8Br|%&?!&=tkKr!y9nXd`{H6`f>ul3Mf>s@@U`q|A z-fnvD93HPG=uL6HvvRBB{HE-i2coqZUloT{yW?s3Td30;PYa0`)A4)Ch#k5v1xu0j z4HP6V&YOjnd!f=lsH7jbR_0O5;u5pZMj0;j6P>_+2A*IpI=yC4Em(6IZ09b+e|B7S zvgUn+aIRUPYj{xpQQ%sk`)lvs0-VpDmvC_-NLon!3BY)cKOz8>$(f0(8K%_hd|=}_ zY11nHAspz`$9dfgAX8NZ9asV706N827mtI^YX!r@rMRRbNc1&X3f0R6yBge7StdE@ zeyW4)wkA_$ZIc^3efuc*tG(pZ;2deUXRDZ_<5|}n$~n$JS~kZWWiWASw~f zuO`xq|F=BFEo3Ey&=arPud=T#;%X#!v5+U#e=@MmdAqoWP|GuyK`A<{S|7CJI p4g@*|jW_8$YUCFTGC literal 0 HcmV?d00001 diff --git a/apps/web/e2e/receipts.spec.ts b/apps/web/e2e/receipts.spec.ts new file mode 100644 index 0000000..e0afc40 --- /dev/null +++ b/apps/web/e2e/receipts.spec.ts @@ -0,0 +1,99 @@ +import { fileURLToPath } from 'node:url' +import { fetchExpenseAttachments, fetchReceiptUrl } from '@potluck/api' +import { formatMoney, t } from '@potluck/i18n' +import type { Page } from '@playwright/test' +import { groupWith } from './support/api' +import { expect, test, unique } from './support/fixtures' + +// A printed till receipt: Corner Bistro, 11/08/2026, burger 14.00, fries 5.00, lemonade 4.50, +// tax 2.00, total 25.50. +const RECEIPT = fileURLToPath(new URL('./fixtures/receipt.png', import.meta.url)) + +async function choose(page: Page, button: string) { + const chooser = page.waitForEvent('filechooser') + await page.getByRole('button', { name: button }).click() + await (await chooser).setFiles(RECEIPT) +} + +test.describe('receipts', () => { + test('scan a receipt, split it by item, keep two photos and keep them in the group', async ({ + newUser, + openAs, + }) => { + // The first scan fetches the reader (worker, core, model) from the preview server. + test.setTimeout(150_000) + const [me, ben, outsider] = await Promise.all([ + newUser({ name: unique('Ava') }), + newUser({ name: unique('Ben') }), + newUser({ name: unique('Oz') }), + ]) + const group = await groupWith(me, [ben]) + const page = await openAs(me) + await page.goto(`/new/expense?groupId=${group.id}`) + + await choose(page, t('expenses.scan.button')) + const review = page.getByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + await expect(review).toBeVisible({ timeout: 90_000 }) + await expect(review.getByText('Corner Bistro')).toBeVisible() + await expect(review.getByText(t('expenses.scan.items', { count: 3 }))).toBeVisible() + await expect(review).toContainText(formatMoney(2550, 'USD')) + await review.getByRole('button', { name: t('expenses.scan.apply') }).click() + await expect(page.getByRole('dialog')).toHaveCount(0) + + // Filled in, not saved: the person checks it and decides who had what. + const name = page.getByRole('textbox', { name: t('expenses.fields.description') }) + await expect(name).toHaveValue('Corner Bistro') + const description = unique('Bistro') + await name.fill(description) + await expect(page.getByLabel(t('expenses.fields.date'))).toHaveValue('2026-11-08') + const items = page.getByRole('textbox', { name: t('expenses.itemized.itemName') }) + await expect(items).toHaveCount(3) + await expect(items.nth(2)).toHaveValue('Lemonade') + await page + .getByRole('group', { name: t('expenses.itemizedEditor.assign', { item: 'Lemonade' }) }) + .getByRole('button', { name: t('common.you') }) + .click() + await expect(page.getByText(t('expenses.form.itemizedTotal'))).toBeVisible() + + // The scanned photo is attached; add one more. + const first = t('expenses.attachments.photo', { number: 1 }) + await expect(page.getByRole('img', { name: first })).toBeVisible() + await choose(page, t('expenses.attachments.addLabel')) + await expect( + page.getByRole('img', { name: t('expenses.attachments.photo', { number: 2 }) }), + ).toBeVisible() + + await page.getByRole('button', { name: t('expenses.form.save') }).click() + await expect(page).toHaveURL(new RegExp(`/groups/${group.id}$`)) + await page.getByRole('link', { name: new RegExp(description) }).click() + await expect(page.getByRole('heading', { level: 1, name: description })).toBeVisible() + const expenseId = page.url().split('/expenses/')[1]!.split(/[/?#]/)[0]! + + // Both photos, each full screen. + await expect(page.getByRole('heading', { name: t('expenses.attachments.label') })).toBeVisible() + await page + .getByRole('button', { name: t('expenses.attachments.open', { name: first }) }) + .click() + const viewer = page.getByRole('dialog', { + name: t('expenses.attachments.position', { number: 1, count: 2 }), + }) + await expect(viewer).toBeVisible() + await expect(viewer.getByRole('img')).toBeVisible() + await viewer.getByRole('button', { name: t('expenses.attachments.next') }).click() + await expect( + page.getByRole('dialog', { + name: t('expenses.attachments.position', { number: 2, count: 2 }), + }), + ).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toHaveCount(0) + + // Group members read the files; nobody else does. + const files = await fetchExpenseAttachments(ben.client, expenseId) + expect(files).toHaveLength(2) + expect(files.every((f) => f.path.startsWith(`${group.id}/`))).toBe(true) + await expect(fetchReceiptUrl(ben.client, files[0]!.path)).resolves.toContain('token=') + expect(await fetchExpenseAttachments(outsider.client, expenseId)).toEqual([]) + await expect(fetchReceiptUrl(outsider.client, files[0]!.path)).rejects.toBeTruthy() + }) +}) diff --git a/apps/web/src/features/expenses/receipts/image.ts b/apps/web/src/features/expenses/receipts/image.ts index 9618dc2..d7b6ae1 100644 --- a/apps/web/src/features/expenses/receipts/image.ts +++ b/apps/web/src/features/expenses/receipts/image.ts @@ -16,15 +16,14 @@ const RESIZABLE = new Set(['image/jpeg', 'image/png', 'image/webp']) /** * Shrinks a photo before it is uploaded or read: at most MAX_EDGE on its longest side, as JPEG * on white (a phone photo of a receipt goes from about 4 MB to about 400 KB). The camera's - * orientation is applied. Anything the browser cannot decode (HEIC outside Safari, PDFs) and - * any photo that would not get smaller is returned as it was. + * orientation is applied, and re-encoding drops the photo's metadata (location included). + * Anything the browser cannot decode (HEIC outside Safari, PDFs) is returned as it was. */ export async function prepareImage(file: File): Promise { if (!RESIZABLE.has(file.type) && !(file.type === 'image/heic' && canDecodeHeic())) return file try { const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }) const size = fitWithin(bitmap.width, bitmap.height) - const resized = size.width !== bitmap.width || size.height !== bitmap.height const canvas = document.createElement('canvas') canvas.width = size.width canvas.height = size.height @@ -37,7 +36,7 @@ export async function prepareImage(file: File): Promise { const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', QUALITY), ) - if (!blob || (!resized && blob.size >= file.size)) return file + if (!blob) return file const name = `${file.name.replace(/\.[^.]*$/, '') || 'photo'}.jpg` return new File([blob], name, { type: 'image/jpeg', lastModified: file.lastModified }) } catch { diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql index b82f0cc..4dc757e 100644 --- a/supabase/tests/29_expense_attachments.test.sql +++ b/supabase/tests/29_expense_attachments.test.sql @@ -3,7 +3,7 @@ -- two-step sign in read none of them; set_expense_attachments and the receipt_path mirror for -- older clients. begin; -select plan(46); +select plan(49); select tests.create_user('ava'); select tests.create_user('ben'); From e87f5afcef73a92c89df998395988bc9342f619c Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:32:51 -0500 Subject: [PATCH 07/29] document receipt scanning and attachments 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> --- AGENTS.md | 2 +- .../0021-receipt-scanning-and-attachments.md | 25 ++++++++ docs/adr/README.md | 1 + docs/architecture.md | 25 ++++++-- docs/receipts.md | 36 ++++++++++++ docs/security.md | 6 +- docs/sequences.md | 49 ++++++++++++++++ docs/splitwise-parity.md | 58 ++++++++++--------- docs/testing.md | 2 + 9 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0021-receipt-scanning-and-attachments.md create mode 100644 docs/receipts.md diff --git a/AGENTS.md b/AGENTS.md index f08f549..544de1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,5 +150,5 @@ Recipes in `.agents/skills/` (copied to `.claude/skills/`). Load the one that fi [development](docs/development.md) (setup, migrations, pgTAP helpers, strings, routes, web feature notes), [architecture](docs/architecture.md) (schema, RPCs, security, realtime, money rules, core and SQL twins), -[testing](docs/testing.md), [security](docs/security.md), [sequences](docs/sequences.md), +[testing](docs/testing.md), [security](docs/security.md), [sequences](docs/sequences.md), [receipts](docs/receipts.md), [ADRs](docs/adr/README.md), [CONTRIBUTING](CONTRIBUTING.md) (branches, commits, PRs). diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md new file mode 100644 index 0000000..1401dda --- /dev/null +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -0,0 +1,25 @@ +# 21. Receipt scanning on the device, and several attachments per expense + +- Status: accepted +- Date: 2026-11-13 + +## Context + +Splitwise Pro reads a receipt photo into the total, the date and the line items, and keeps several high resolution photos per expense. Potluck had one receipt per expense (`expenses.receipt_path`) and no scanning. Hosted OCR APIs cost money per page and would send every receipt (names, card digits, addresses) to a third party. Potluck has no application server, the app must stay light for people who never scan, and the installed PWA should keep working offline. Older clients only know `receipt_path`. + +## Decision + +- **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **A scan is a suggestion.** The review sheet shows each part with its own box. "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before reading and uploading, which also drops their metadata. +- **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. +- **`receipt_path` stays the first attachment.** The new client sends the first file as `receipt_path` on create and sets the rest with `set_expense_attachments`; the function keeps `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. + +## Consequences + +- No per-scan cost and no receipt leaves the device for reading. The first scan downloads about 7 MB (worker, one core build, model); a device without that cache and without a connection cannot scan and says so. +- Accuracy is the open source engine's: clean printed till receipts read well, crumpled or handwritten ones do not, and non-Latin scripts are not read (English model only). The review step and the boxes keep a bad read from becoming a bad expense. +- `tesseract.js` and its core are in `package.json` with exact versions. Upgrading changes the `/ocr//` path, so an old cache is never mixed with a new worker. +- Two writes on save (the expense, then its files) are not atomic. If the second fails, the expense is saved with its first file and the person is told to add the rest by editing. +- Files removed by someone who did not upload them, and is not an admin, stay in storage (the bucket's delete rule), unreferenced but readable by the group. +- The parser is heuristics. New receipt formats are new test cases in `parse-receipt.test.ts`. diff --git a/docs/adr/README.md b/docs/adr/README.md index e1fc7d3..2f102d1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,6 +24,7 @@ Short notes on decisions that shape the codebase: what we were facing, what we c | [0018](0018-export-backup-and-import.md) | Export, backup format and import mapping | | [0019](0019-placeholder-members-and-claiming.md) | Placeholder members and claiming them | | [0020](0020-group-types-restorable-deletes-and-shared-notes.md) | Group types, restorable deletes, covers and shared notes | +| [0021](0021-receipt-scanning-and-attachments.md) | Receipt scanning on the device, several attachments | ## Writing a new one diff --git a/docs/architecture.md b/docs/architecture.md index 162b6c6..8562393 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,7 +48,7 @@ flowchart TB rest["PostgREST
    tables + RPCs in public"] rt["Realtime
    postgres_changes"] storage["Storage
    avatars, group-covers, receipts"] - edge["Edge Functions (Deno)
    notify, fx-sync, ics-feed, delete-account,
    mfa-recover, cancel-email-change"] + edge["Edge Functions (Deno)
    notify, fx-sync, ics-feed, delete-account,
    mfa-recover, cancel-email-change,
    receipts-sweep"] subgraph pg["Postgres 17"] public["public schema
    tables, RLS, definer RPCs"] private["private schema
    policy helpers, triggers, jobs"] @@ -294,6 +294,14 @@ erDiagram bigint amount_cents uuid_array user_ids } + expense_attachments { + uuid expense_id PK, FK + smallint position PK "0 to 4" + uuid group_id FK + text path "group_id/..., receipts bucket" + text content_type + uuid uploaded_by FK + } recurring_expenses { uuid id PK uuid group_id FK @@ -513,6 +521,7 @@ erDiagram - `expense_splits`: `(expense_id, user_id)` pk, `share_cents bigint not null`, `weight numeric` (percent or share count, for display), `adjustment_cents bigint` (adjustment splits). Constraint (deferred, via trigger): splits sum to `amount_cents`. - `expense_payers`: `(expense_id, user_id)` pk, `paid_cents bigint > 0`, in the expense currency. Every expense has at least one row (backfilled from `paid_by`; a trigger keeps a single payer in step with `paid_by` and `amount_cents`, so writers that only know `paid_by` stay correct). Constraint (deferred, via trigger): payers sum to `amount_cents` (`payers_sum_mismatch`). Members read; writes only through the expense RPCs. - `expense_items`: `(expense_id, position)` pk, `description`, `amount_cents`, `user_ids uuid[]`. A by-item bill as entered, so editing reopens it; the shares stay in `expense_splits`. Members read; written and dropped by `save_expense` together with the split. +- `expense_attachments`: `(expense_id, position)` pk (0 to 4: five files at most), `group_id`, `path` (in the `receipts` bucket, must start with `/`), `content_type` (png, jpeg, webp, heic or pdf), `uploaded_by`, `created_at`. Receipts, photos and PDFs on an expense, in order; the first is also `expenses.receipt_path`, which older clients still read and write (a trigger mirrors their writes into the list). Members read; written only by `set_expense_attachments` and that trigger. See [ADR 21](adr/0021-receipt-scanning-and-attachments.md) and [receipts](receipts.md). - `recurring_expenses`: template rows (`group_id`, `description`, `amount_cents`, `currency`, `paid_by`, `payers jsonb` (null when one person pays), `split_type`, `splits jsonb`, `items jsonb` with `tax_cents`, `tip_cents`, `discount_cents` (a by-item bill as entered, null otherwise; `splits` then holds the shares worked out from it), `recurrence`, `next_run_on date`, `anchor_day smallint` (day of month monthly and yearly ones repeat on; null falls back to the first posted expense's day), `active bool`, `notes` (copied to every posted expense)). `private.recurring_heads_ups (recurring_id, posts_on, user_id)` records who heard about which occurrence. A `pg_cron` job materializes due rows into `expenses` daily; changing a template only changes the copies still to come ([ADR 14](adr/0014-editable-recurring-templates.md)). - `settlements`: `id`, `group_id`, `from_user`, `to_user`, `amount_cents > 0`, `currency`, `fx_rate`, `note`, `settled_at`, `created_by`, `deleted_at` (soft delete, restorable). A payment with a picked day is stored at noon of that day in the recorder's time zone (their profile) and keeps that day's rate. - `payment_methods`: `(user_id, kind)` pk, `kind` (`paypal`, `venmo`, `cashapp`, `revolut`, `monzo`, `upi`, `interac`, `bank`), `handle` (normalized, checked by `private.valid_payment_handle`, same rules as `isValidHandle` in `packages/core`), timestamps. The owner writes; people who share a group with the owner (and no block between them) read. Cleared when the account closes. See [ADR 15](adr/0015-payment-links-and-cross-group-settle.md). @@ -600,6 +609,8 @@ A placeholder is always a plain member and can never be made admin or owner (`pl | `import_stage(batch_id, group_id, rows)` → rows staged so far | any member; up to 2,000 rows a call into `private.import_rows`, written by the `import_expenses` call that names the batch | | `export_my_data()` → jsonb | the caller's whole account, format `potluck-backup` version 1 ([backup-format.md](backup-format.md)) | | `delete_expense(id)` / `restore_expense(id)` | soft delete | +| `set_expense_attachments(id, attachments jsonb)` → attachments | replaces an expense's files (uploaded under the group folder, five at most); whoever may edit the expense; one history entry | +| `create_expense_with_attachments(payload, attachments jsonb)` → expense | `create_expense_v2` with its files in one transaction; one history entry | | `update_recurring_expense(id, payload jsonb)` → template | an admin or anyone it involves (creator, payer, someone in the split); the `create_expense_v2` payload plus `recurrence`, `next_run_on`; only future copies change ([ADR 14](adr/0014-editable-recurring-templates.md)) | | `skip_recurring_expense(id)` → template | an admin or anyone it involves; moves `next_run_on` one period on; logged as `recurring.skipped` | | `make_expense_recurring(expense_id, recurrence)` → template | whoever may edit the expense; first copy one period after its date, or the next date still to come | @@ -664,7 +675,7 @@ Codes: - 1:1 tabs and friends: `invalid_friend`, `not_a_friend`, `friend_has_balance` (removing a friend with an open 1:1 balance), `person_not_found` (blocking someone you do not know). - People not on Potluck yet: `already_member` (adding an email that is already in the group), `profile_without_account`, `placeholder_member` (a placeholder as admin, owner, or in another group), `placeholder_not_found`, `placeholder_claim_conflict` (the account is or was already in the group), `placeholder_exists` (same email waiting in the group), `placeholder_no_email`, `placeholder_already_reminded`, `invalid_name`, `invalid_email`, `too_many_invites` (20 emailed invites a day, 3 addresses per placeholder), `email_not_verified` (claiming, or an email-bound friend link, needs a confirmed email). - Invites: `direct_group`, `invalid_role`, `invalid_max_uses`, `invalid_expiry`, `invite_not_found`, `invite_expired`, `invite_used_up`, `invite_revoked`, `invite_wrong_email`. -- Expenses: `invalid_payload`, `group_required`, `description_required`, `invalid_amount`, `invalid_receipt_path`, `payer_not_in_group`, `fx_rate_unavailable`, `expense_not_found`, `expense_deleted`, `event_not_in_group` (linking an expense to a plan in another group), `split_member_not_in_group` (moving money of someone who left), `invalid_payers`, `payers_sum_mismatch` (what each person paid does not add up to the amount, also from the deferred check). +- Expenses: `invalid_payload`, `group_required`, `description_required`, `invalid_amount`, `invalid_receipt_path`, `too_many_attachments`, `attachment_not_found`, `payer_not_in_group`, `fx_rate_unavailable`, `expense_not_found`, `expense_deleted`, `event_not_in_group` (linking an expense to a plan in another group), `split_member_not_in_group` (moving money of someone who left), `invalid_payers`, `payers_sum_mismatch` (what each person paid does not add up to the amount, also from the deferred check). - Splits: `invalid_splits`, `splits_required`, `splits_sum_mismatch`, `split_amount_required`, `percent_sum`, `invalid_weight`, `zero_shares`, `duplicate_user`, `adjustments_exceed_total`, `negative_share`, `items_required`, `invalid_items`. - Repeating expenses: `recurring_not_found`, `already_recurring` (the expense is already part of a series), `invalid_next_run` (a next date in the past). - Payments: `invalid_recipient`, `invalid_parties` (payer and payee must differ), `not_a_party` (a member recording a payment between two others), `note_too_long`, `settlement_not_found`, `settlement_deleted` (editing a deleted payment), `member_not_in_group` (moving the balance of someone who has left), `nothing_owed` and `already_reminded` (reminders, picked groups), `invalid_payment_handle` (payment methods). @@ -706,8 +717,9 @@ Codes: - `create_expense(payload)` / `update_expense(id, payload)` / `delete_expense(id)` / `restore_expense(id)` return the `expenses` row. - Payload keys: read `private.save_expense` in `supabase/schema/current.sql` (the live version; the comment at the top of the first expenses migration is older). - Extra payload key `recurrence` (`weekly` | `biweekly` | `monthly` | `yearly`, create only) also creates a `recurring_expenses` template whose first run is one period after `spent_at` (`private.recurring_step`); a backdated one catches up. A by-item bill's items, tax, tip and discount are stored on the template too. - - `receipt_path` must start with `/`. + - `receipt_path` must start with `/`. It is the first attachment: setting it from an older client replaces that file in `expense_attachments`, clearing it drops that file and the next one becomes the receipt. - Members edit expenses they created or paid (any part of); admins and the owner edit any. +- `set_expense_attachments(id, attachments)` and `create_expense_with_attachments(payload, attachments)`: see [receipts](receipts.md) for its checks, the history entry and the orphan sweep. - `create_expense_v2(payload)` / `update_expense_v2(id, payload)` ([ADR 12](adr/0012-several-payers-refunds-and-stored-items.md)) take everything v1 does, plus: - `payers`: `[{user_id, paid_cents}]`, whole minor units, zero dropped, one row per person, summing to `amount_cents`; `paid_by` becomes whoever paid the most (ties: smaller id). Without `payers`, `paid_by` alone means one payer; on an expense several people paid, the same `paid_by` and amount keep the stored payers, a new amount refuses with `payers_sum_mismatch`. Only a new payer must be a member. - `is_refund`: the payers received the money; balances flip both signs. A refund cannot repeat. @@ -866,7 +878,7 @@ Who can read what: ### Activity and notifications -Activity verbs: `group.created`, `group.updated` (also type and trip dates), `group.default_split`, `group.deleted`, `group.restored`, `group.notes_updated`, `member.joined`, `member.added` (someone put a placeholder, or an account by its email, in the group; the actor is who added them), `member.claimed` (a placeholder was taken over by an account; `data.name` is the seat's name), `member.invite_changed` (a seat's invite email changed; the row never holds an address), `member.left`, `member.removed`, `member.role_changed`, `expense.created`, `expense.updated`, `expense.deleted`, `expense.restored`, `settlement.created`, `settlement.updated`, `settlement.deleted`, `settlement.restored`, `event.created`, `event.scheduled` (also on reschedule), `event.cancelled`, `availability.requested`, `comment.created`, `recurring.created`, `recurring.updated`, `recurring.skipped`, `recurring.paused` (subject: the template; paused when someone in it left, with `user_ids`, no actor), `expenses.imported` (one per import, `{expenses, payments, source, fingerprint}`). Every `expense.updated` row carries `changes`: `{field: {from, to}}` for each field the edit changed (`description`, `amount_cents`, `currency`, `spent_at`, `category`, `notes`, `is_refund`, `receipt`, `event_id`, `tax_cents`, `tip_cents`, `discount_cents`, `items` (the bill's lines as entered), plus `payers` and `split` as `{type, shares}` when those changed beyond rescaling to a new amount; `private.expense_row_changes` builds the row part). `private.save_expense` writes that row itself, once per edit, so payer and split changes are in it; it sets `potluck.expense_logged` for the update so the `expenses_activity` trigger stays out of the way, and the trigger still logs (with row `changes`) any other update. An edit that only changes the split or who paid is also notified as `expense.updated` from `save_expense`, since the notification triggers only compare expense columns; people dropped from the split or the payers are notified too. Every payer hears about a new expense, like the people in the split. The expense page reads its history (`expense.*` rows for that subject) from `activity` and renders every change, old value to new. +Activity verbs: `group.created`, `group.updated` (also type and trip dates), `group.default_split`, `group.deleted`, `group.restored`, `group.notes_updated`, `member.joined`, `member.added` (someone put a placeholder, or an account by its email, in the group; the actor is who added them), `member.claimed` (a placeholder was taken over by an account; `data.name` is the seat's name), `member.invite_changed` (a seat's invite email changed; the row never holds an address), `member.left`, `member.removed`, `member.role_changed`, `expense.created`, `expense.updated`, `expense.deleted`, `expense.restored`, `settlement.created`, `settlement.updated`, `settlement.deleted`, `settlement.restored`, `event.created`, `event.scheduled` (also on reschedule), `event.cancelled`, `availability.requested`, `comment.created`, `recurring.created`, `recurring.updated`, `recurring.skipped`, `recurring.paused` (subject: the template; paused when someone in it left, with `user_ids`, no actor), `expenses.imported` (one per import, `{expenses, payments, source, fingerprint}`). Every `expense.updated` row carries `changes`: `{field: {from, to}}` for each field the edit changed (`description`, `amount_cents`, `currency`, `spent_at`, `category`, `notes`, `is_refund`, `receipt`, `event_id`, `tax_cents`, `tip_cents`, `discount_cents`, `items` (the bill's lines as entered), `attachments` (`{from, to}` file counts, from `set_expense_attachments`), plus `payers` and `split` as `{type, shares}` when those changed beyond rescaling to a new amount; `private.expense_row_changes` builds the row part). `private.save_expense` writes that row itself, once per edit, so payer and split changes are in it; it sets `potluck.expense_logged` for the update so the `expenses_activity` trigger stays out of the way, and the trigger still logs (with row `changes`) any other update. An edit that only changes the split or who paid is also notified as `expense.updated` from `save_expense`, since the notification triggers only compare expense columns; people dropped from the split or the payers are notified too. Every payer hears about a new expense, like the people in the split. The expense page reads its history (`expense.*` rows for that subject) from `activity` and renders every change, old value to new. Notification types: `group.deleted`, `group.restored` (the other members; category groups), `expense.created` (split members, with their `share_cents`), `expense.updated`, `expense.deleted`, `expense.restored` (everyone in the expense), `settlement.created` (both people in the payment), `settlement.updated`, `settlement.deleted`, `settlement.restored` (both people in the payment, old and new; an edit counts when it changes people, amount, currency or day), `debt.reminder` (the person reminded, with the note), `member.added` (someone else put you in a group, not 1:1 tabs: a placeholder's invite email and its one reminder (`reminder: true`), an account added by its email, and the inbox note an account gets when signing up claims a seat), `member.invite_changed` (whoever added a seat, when someone else changes its email), `friend.added` (someone accepted your friend link), `summary.monthly` (monthly job, everyone with a non-zero balance), `member.joined` (owner and admins, not for direct groups), `event.created`, `event.scheduled`, `event.cancelled`, `availability.requested`, `comment.created`, `event.reminder` (going or maybe), `rsvp.reminder` (no answer yet), `recurring.upcoming` (at 09:00 on each person's local day before a repeating expense posts, or as soon as possible if that passed, never after it posts, inbox only when that is late and at night (`data.quiet`); to its payers and everyone with a share, each with their own `share_cents`, `paid_cents`, the `posts_on` date, the `deadline` (00:10 UTC that day) and their `timezone`; Reminders setting), `recurring.paused` (one was paused because someone in it left; to whoever set it up, else the owner and admins; edits and deletions setting). The actor and closed accounts are never notified. `data` carries `group_id`, the subject id and `actor_id`. @@ -924,7 +936,7 @@ Paths start with an owning id, and policies parse it with `storage.foldername(na | `group-covers` | by URL | `` | owner and admins of a live group; members list | 10 MB, png/jpeg/webp | | `receipts` | no | `` | members upload; the uploader (`owner_id`) or an admin replaces or deletes; members read | 10 MB, images, heic or pdf | -`create_expense` also refuses a `receipt_path` that does not start with `/`, so an expense cannot point at another group's receipt. +`create_expense` also refuses a `receipt_path` that does not start with `/`, and `set_expense_attachments` and a check on `expense_attachments` do the same for every attachment, so an expense cannot point at another group's files. Attachments (up to five per expense) live in `receipts` too, so the member, uploader and two-step policies above cover every one of them ([ADR 21](adr/0021-receipt-scanning-and-attachments.md)). ### Edge function auth @@ -935,6 +947,7 @@ Each function uses one of three schemes. `verify_jwt` is set per function in `su | `delete-account` | true | the user's JWT | the gateway, then `auth.getUser()`; the RPC runs as the caller so its own checks apply | | `notify` | false | `x-potluck-secret` header equal to `EDGE_SECRET` | `hasEdgeSecret()`, constant-time compare; only `private.invoke_edge_function` knows the secret (from Vault) | | `fx-sync` | false | same shared secret | same | +| `receipts-sweep` | false | same shared secret | same | | `ics-feed` | false | `?token=` from `calendar_feed_tokens` | lookup with the service role; unknown or short tokens get a plain 404. Calendar apps cannot send headers, hence the query string | | `unsubscribe` | false | `?token=` signed unsubscribe token | no privilege of its own: a POST (RFC 8058 one-click) calls the anon `email_unsubscribe` RPC with the publishable key; Postgres checks the HMAC. GET only redirects to the web page, which asks before changing anything | | `mfa-recover` | true | the user's JWT (`aal1` is enough) and a code | the gateway, then `auth.getUser()`; the code is checked by `mfa_recovery_code_matches` with the service role | @@ -975,6 +988,7 @@ All jobs are `pg_cron` entries created by migrations, so every environment gets | `recurring-heads-up` | `5,35 * * * *` | `private.send_recurring_heads_ups()` | `recurring.upcoming` to everyone a template names (payers and people with a share), with their own share, at 09:00 on their local day before it posts (`profiles.timezone`, UTC when Postgres does not know it), or on the next run if that passed, never at or after 00:10 UTC on the posting date; a late one landing between 22:00 and 07:00 local is marked quiet and stays in the inbox (no push, no email). Once per person and occurrence (`private.recurring_heads_ups`). Skips paused templates, archived groups and templates naming someone who left; each person opts out with their Reminders setting | | `event-reminders` | `5 * * * *` | `private.send_event_reminders()` then `private.finish_past_events()` | `event.reminder` to going and maybe, `rsvp.reminder` to members who have not answered, once per event starting in the next 24 hours. Then flips ended scheduled events to `done` | | `fx-sync` | `30 16 * * *` | `private.invoke_edge_function('fx-sync')` | Asks the edge function to pull the day's ECB rates. The ECB publishes around 16:00 CET | +| `receipts-sweep` | `40 3 * * *` | `private.invoke_edge_function('receipts-sweep')` | Asks the edge function to delete receipt files older than a day that no expense or attachment uses (`orphan_receipts`), through the Storage API | | `email-notifications` | `* * * * *` | `private.send_email_notifications()` | Does nothing while `app_config.email_notifications` is off. Otherwise hands `notify` the outbox rows whose subject has been quiet for 60 seconds, one email per expense (nothing if it was added and deleted inside the hold), re-checking settings, in batches of 50 per call. Re-sends batches with no report after 10 minutes (same batch id, the provider's idempotency key, when exactly the same rows go again; otherwise a new one), retries reported failures with backoff up to 5 attempts, drops rows older than a day | | `monthly-summary` | `0 15 1 * *` | `private.send_monthly_summaries()` | One `summary.monthly` notification (inbox card, email when on, never push) for everyone with a non-zero balance, about the month that just ended | | `purge-import-batches` | `*/15 * * * *` | `delete from private.import_batches` | Drops staged import parts older than an hour (an import nobody finished), with their rows | @@ -1006,6 +1020,7 @@ Each recurring template (in both recurring jobs) runs inside its own `begin ... - `unsubscribe`: one-click unsubscribe target (RFC 8058) for the `List-Unsubscribe` header. - `ics-feed`: per-user `.ics` subscription URL (token in query string). - `fx-sync`: daily FX rates from Frankfurter. +- `receipts-sweep`: deletes orphaned receipt files listed by `orphan_receipts` (service role only), through the Storage API so the stored objects go too ([receipts](receipts.md)). - `delete-account`: runs `delete_my_account()` as the caller, then soft deletes the auth user with the service role. - `mfa-recover`: trades a two-step recovery code for removing the caller's authenticator factors (logic in `recover.ts`, tested under Node). - `cancel-email-change`: calls off a pending email change by setting the address back to the current one with the admin API. diff --git a/docs/receipts.md b/docs/receipts.md new file mode 100644 index 0000000..3c647c1 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,36 @@ +# Receipts: scanning and attachments + +How a receipt photo becomes an expense, and how files are kept on expenses. The decision is [ADR 21](adr/0021-receipt-scanning-and-attachments.md); the flow is drawn in [sequences.md](sequences.md#scan-a-receipt-and-attach-files). + +## Where the code is + +| Part | Where | +| -------------------------------------- | ------------------------------------------------------------------------------ | +| Parser (text to merchant, date, items) | `packages/core/src/receipts/parse-receipt.ts` | +| OCR worker (tesseract.js) | `apps/web/src/features/expenses/receipts/ocr.ts` | +| Image prep (OCR copy, upload copy) | `apps/web/src/features/expenses/receipts/image.ts` | +| Scan button and review sheet | `apps/web/src/features/expenses/receipts/scan-receipt.tsx` | +| What a scan fills in the form | `apps/web/src/features/expenses/receipts/scan-fill.ts` | +| Files on the form, upload and cleanup | `attachments-field.tsx`, `attachments-state.ts` (same folder) | +| Files on the expense page, viewer | `attachment-gallery.tsx`, `use-zoom-pan.ts` (same folder) | +| Reader files served from our origin | `apps/web/ocr-assets.ts` (`/ocr//`, cached by the service worker) | +| API | `packages/api/src/attachments.ts` | +| Table, RPC, mirror trigger, sweep | `supabase/migrations/*_expense_attachments.sql`, test `29_expense_attachments` | + +## Rules + +- Only `ocr.ts` imports `tesseract.js`, and only with a dynamic `import()`, so it never lands in the app bundle or the precache. Its worker, core and model come from `/ocr//` on our origin. +- OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, a second pass reads an upscaled copy and the better reading wins. +- The upload copy (`prepareImage`) is at most 2000 px, JPEG on white. Re-encoding drops the photo's metadata, location included. HEIC outside Safari and PDFs go up as they are. +- A scan only fills the form. The review sheet shows the photo and lets each price be corrected; the person saves the expense. +- The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off). +- Tests mock `./ocr`: jsdom has no workers or canvas. + +## Attachments in the database + +- `expense_attachments` holds up to five files per expense, in order. Files live in the private `receipts` bucket under `/`, so the bucket's member, uploader and two-step policies are the only storage rules. +- `set_expense_attachments(id, attachments)`: a list of storage paths (or `{path}` objects). Group row share lock, member, `require_group_open`, own expense or admin, not on a deleted expense. Each path starts with `/`, appears once and exists in the bucket (`attachment_not_found`); more than five is `too_many_attachments`. An unchanged list writes and logs nothing. Otherwise it rewrites the rows, sets `receipt_path` to the first file and logs one `expense.updated` with `changes.attachments = {from, to}`, except right after the caller created the expense (only its `expense.created` row so far, under ten minutes old): the files are part of creating it, so history keeps one entry. +- `receipt_path` is always the first attachment. A trigger mirrors an older client's writes: its file is replaced or dropped and the others stay. +- Dropped files are deleted by the client when the bucket lets it (uploader or admin). Files uploaded for a save that never happened are deleted when the form closes, and kept for a retry while it is open. +- The daily `receipts-sweep` job finds files in the bucket older than a day that no expense or attachment names (`private.orphan_receipts`) and has the `receipts-sweep` edge function delete them through the Storage API, which also removes the stored object. Deleting rows from `storage.objects` in SQL would leave the object behind. +- pgTAP deletes from `storage.objects` need `set_config('storage.allow_delete_query', 'true', true)`, as the Storage API sets. diff --git a/docs/security.md b/docs/security.md index 51f0c71..9460360 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,7 +9,7 @@ How Potluck protects its data, what it stores, and where the known weak spots ar - **Money history.** Who paid whom, how much, and when. Tampering with it moves real debts between friends. - **The social graph.** Who is in which group, who is friends with whom, who blocked whom. - **Plans.** Availability grids and event locations say where people will be and when. -- **Receipts.** Photos can carry addresses, card digits and location metadata. +- **Receipts.** Photos can carry addresses, card digits and location metadata. An expense holds up to five (photos and PDFs), all in the private `receipts` bucket. - **Account data.** Email addresses and display names. ### Who we defend against @@ -40,6 +40,7 @@ Out of scope: a compromised Supabase or Cloudflare account, a malicious maintain These are real today. Each should become an issue. - **Closed accounts keep their calendar feed.** `delete_my_account()` does not delete the `calendar_feed_tokens` row, and `ics-feed` does not check `profiles.deleted_at`. A closed account that still has an open balance in a group keeps that group's membership, so its old feed URL keeps listing that group's events. +- **Removed attachments can linger.** Taking a file off an expense deletes it only when the person removing it uploaded it or is an admin (the bucket's delete rule); otherwise the file stays in the group folder, readable by members, with nothing pointing at it. Deleting an expense keeps its files (it can be restored). - **Avatar files outlive the account.** The profile's `avatar_path` is cleared, but the file stays in the public `avatars` bucket, at a URL anyone who saw it can still open. - **Admin invite rule mismatch.** `can()` in core lets admins create admin invites. The server refuses, so this is a UI bug, not a hole. - **Previews use production data.** PR preview builds point at the production Supabase project. Anyone who can open a PR that gets a preview deploy can ship code that runs against real accounts. Today previews only build for branches in this repository, because fork PRs do not get the Cloudflare secret. @@ -79,7 +80,7 @@ These are real today. Each should become an issue. | Busy blocks, calendar links | `busy_blocks`, `calendar_connections` | only you. OAuth tokens would live in Vault (feature is off) | | Notifications, push subscriptions | `notifications`, `push_tokens` | only you | | Notification settings | `notification_prefs` | only you | -| Receipts | private `receipts` bucket | members of the group (authenticated download or signed URL) | +| Receipts and other attachments (up to five per expense) | private `receipts` bucket, `expense_attachments` | members of the group (authenticated download or signed URL) | | Avatars, group covers | public buckets | anyone with the URL | | Authenticator secrets, recovery code hashes | `auth.mfa_factors`, `private.mfa_recovery_codes` | nobody through the API. Supabase operators | @@ -88,6 +89,7 @@ There is no analytics or tracking code in the app. ### What leaves the system - **Push messages** go through the browser vendor's push service (Google, Mozilla, Apple). Web push payloads are encrypted for the subscribing browser, so the service cannot read the title and body, which may include an expense description or a comment. +- **Receipt scanning** sends nothing anywhere. The photo is read in a web worker on the device (tesseract.js); the reader's code and English model are downloaded from Potluck's own origin, not a CDN. The photo leaves the device only if the person keeps it attached and saves. Photos are re-encoded to JPEG before upload, which drops their EXIF metadata (location included); HEIC photos the browser cannot decode, and PDFs, are uploaded as they are. - **FX sync** sends nothing about users to Frankfurter. It requests public reference rates. - **Exports and backups** (Me, Your data; group settings) are built in the browser and saved to the person's device; nothing is sent elsewhere. `export_my_data()` returns only groups the caller is in now, with the names of everyone those groups mention, and the caller's own sign in email; anon cannot call it. Imports run as the importing member with every check `create_expense_v2` makes ([ADR 18](adr/0018-export-backup-and-import.md)). - **Calendar feeds** contain event titles, descriptions, locations and group names for whoever has the URL. diff --git a/docs/sequences.md b/docs/sequences.md index d9b51fd..89e70aa 100644 --- a/docs/sequences.md +++ b/docs/sequences.md @@ -19,6 +19,7 @@ Participants used throughout: - [Delete and restore a group](#delete-and-restore-a-group) - [Group notes](#group-notes) - [Add an expense](#add-an-expense) +- [Scan a receipt and attach files](#scan-a-receipt-and-attach-files) - [Settle up](#settle-up) - [Settle with a friend across groups](#settle-with-a-friend-across-groups) - [Remind someone who owes you](#remind-someone-who-owes-you) @@ -331,6 +332,54 @@ sequenceDiagram The service worker shows the message. The PWA is built with `vite-plugin-pwa` in `generateSW` mode, and `apps/web/public/push-sw.js` (pulled in through `workbox.importScripts` in `apps/web/vite.config.ts`) handles `push` by showing a notification and `notificationclick` by focusing or opening the app at the payload's `url`, same origin only. The browser subscribes from the Me page, which stores the endpoint in `push_tokens`; sign out drops that row and the subscription first so the next account on the device does not inherit it. +## Scan a receipt and attach files + +Scanning happens on the device and only fills the form; the expense is saved by the same `create_expense_v2` call as above ([ADR 21](adr/0021-receipt-scanning-and-attachments.md)). + +```mermaid +sequenceDiagram + actor P as Person + participant W as Web (expense form) + participant SW as Service worker + participant O as OCR worker (tesseract.js) + participant C as core + participant S as Storage (receipts) + participant DB as DB + + P->>W: Scan a receipt, pick or take a photo + W->>W: prepareImage: at most 2000 px, JPEG on white + W->>W: import("tesseract.js") (lazy chunk) + W->>O: createWorker(eng, LSTM only) from /ocr// + O->>SW: worker.min.js, core .wasm.js, eng.traineddata.gz + alt first scan + SW->>SW: fetch from our origin, keep in cache potluck-ocr + else later scans, online or not + SW-->>O: from cache + end + O-->>W: text, then the worker is terminated + W->>C: parseReceipt(text, currency, date order, today) + C-->>W: merchant, date, currency, total, items, tax, tip, discount + W->>P: review sheet, one box per part + P->>W: Fill in the expense + W->>W: description, date, amount, by-item draft (everyone on every line),
    photo added to the attachments (nothing saved) + P->>W: pick who had what, add more files, Save + par each new file + W->>S: upload receipts/:groupId/:file (policy: member, two-step done) + end + W->>DB: rpc create_expense_v2(payload, receipt_path = first file) + DB->>DB: trigger: expense_attachments row 0 = receipt_path + opt more than one file (or an edit changed them) + W->>DB: rpc set_expense_attachments(id, paths in order) + DB->>DB: group share lock, member, own expense or admin,
    paths in the group folder and uploaded, five at most + DB->>DB: rewrite expense_attachments, receipt_path = first,
    activity expense.updated {attachments: {from, to}} + W->>S: remove files that left the list (uploader or admin only) + end + DB-->>W: Realtime: expenses change + W->>W: invalidate the expense (its attachments key sits under it) +``` + +On the expense page each photo opens full screen through a signed URL (one hour). Members of other groups get no rows from `expense_attachments` and no object from the bucket. + ## Settle up A member records a payment they are part of, in either direction ("I paid Ben", "Ben paid me"); owners and admins also between two others. The day defaults to today; a past day keeps that day's FX rate. If the recipient saved payment methods, the payer can open their app first (ADR 15): Potluck builds the link on the client and records nothing until the payer confirms. diff --git a/docs/splitwise-parity.md b/docs/splitwise-parity.md index 3ecf9b8..f1b3489 100644 --- a/docs/splitwise-parity.md +++ b/docs/splitwise-parity.md @@ -19,33 +19,33 @@ What Splitwise Free holds back today: 4 new expenses per day, ads, and the Pro f ### Expense entry -| Splitwise feature | Tier | Potluck | Notes | -| ----------------------------------------------------------------------- | ---------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Split equally | Free | exists | `equal` split type | -| Split by exact amounts | Free | exists | `exact` | -| Split by percentages | Free | exists | `percent`, must total 100 | -| Split by shares | Free | exists | `shares` | -| Split by adjustment (change one person's share, the rest split equally) | Free | exists | `adjustment` split type: +/- per person, the rest equal; core `splitAdjustment`, SQL `compute_split_rows` (Batch 1) | -| Refund or reimbursement (money coming back to the group) | Free (web) | exists | `expenses.is_refund`: whoever got the money owes the split their part; signs flip in balances, CSV shows it negative (Batch 1) | -| "You owe the full amount" / "they owe the full amount" shortcuts | Free | exists | four one-tap options on the split when two people share a group (Batch 1) | -| Multiple payers ("paid by multiple people") | Free | exists | `expense_payers`; "Paid by" sheet with one or several people; balances, rows, detail and CSV credit each payer (Batch 1, ADR 12) | -| Itemize a bill by hand (line items, tax, tip, discount) | Free (unverified) | exists | items, tax, tip and discount stored in `expense_items` and on the expense; editing reopens them; the server works out shares from the items (Batch 1) | -| Receipt scanning (photo fills total, date, items) | Pro; free for restaurant bills in US and UK since Oct 2026 | missing | no OCR; receipts are attachments only | -| Diners join a restaurant bill by QR code without an account | Free (US, UK, Oct 2026) | missing | depends on placeholder people (Batch 4) and scanning (Batch 10) | -| Attach a receipt or photo | Free (Pro adds 10 GB high-res storage) | partial | one image or PDF per expense, 10 MB; no multiple attachments, no photos on comments or payments | -| Notes on an expense | Free | exists | `expenses.notes`, 2000 chars | -| Categories with subcategories | Free | exists | 16 categories, 44 subcategories (taxi, flights, electricity, ...) stored as their own ids, rolled up with `parentCategory()` (Batch 9, [ADR 20](adr/0020-group-types-restorable-deletes-and-shared-notes.md)) | -| Auto-categorize from the description | unverified (users ask for it) | exists | the category follows the description until you pick one: the group's own history first, then a keyword list in all five languages ("Uber" is Transport) (Batch 9) | -| Expense date (past or future) | Free | exists | `spent_at` date picker | -| Expense in another currency | Free | exists | per-expense currency, FX snapshot into the group currency | -| New expense defaults to the last currency used in the group | Free | exists | starts in the currency of the last expense you added in that group (Batch 1) | -| Currency conversion of balances | Pro (converts a whole group, past expenses too, at market rates) | exists | Potluck converts every expense into the group currency at its date's ECB rate, so balances are always in one currency | -| Save a default split for a group | Pro | exists | `groups.default_split` via `set_default_split`; any member, from the expense form or group settings; new expenses start from it (Batch 1) | -| Add an expense from anywhere (quick add) | Free | exists | quick-add sheet with group picker | -| Link an expense to an event | n/a | exists | Potluck extra | -| Expense edit history | Free | exists | expense page lists who added, edited, deleted and restored it, and every field each edit changed (old and new values, payers and split too) (Batch 1) | -| Offline expense entry with sync | Free | partial | the installed PWA loads offline; writes need a connection | -| Comments on an expense | Free | exists | expense comments with realtime | +| Splitwise feature | Tier | Potluck | Notes | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Split equally | Free | exists | `equal` split type | +| Split by exact amounts | Free | exists | `exact` | +| Split by percentages | Free | exists | `percent`, must total 100 | +| Split by shares | Free | exists | `shares` | +| Split by adjustment (change one person's share, the rest split equally) | Free | exists | `adjustment` split type: +/- per person, the rest equal; core `splitAdjustment`, SQL `compute_split_rows` (Batch 1) | +| Refund or reimbursement (money coming back to the group) | Free (web) | exists | `expenses.is_refund`: whoever got the money owes the split their part; signs flip in balances, CSV shows it negative (Batch 1) | +| "You owe the full amount" / "they owe the full amount" shortcuts | Free | exists | four one-tap options on the split when two people share a group (Batch 1) | +| Multiple payers ("paid by multiple people") | Free | exists | `expense_payers`; "Paid by" sheet with one or several people; balances, rows, detail and CSV credit each payer (Batch 1, ADR 12) | +| Itemize a bill by hand (line items, tax, tip, discount) | Free (unverified) | exists | items, tax, tip and discount stored in `expense_items` and on the expense; editing reopens them; the server works out shares from the items (Batch 1) | +| Receipt scanning (photo fills total, date, items) | Pro; free for restaurant bills in US and UK since Oct 2026 | exists | on the device, free and offline after the first scan: tesseract.js in a web worker, core `parseReceipt`; a review sheet fills name, date, total and items into the by-item editor, never saves on its own (Batch 10, ADR 21) | +| Diners join a restaurant bill by QR code without an account | Free (US, UK, Oct 2026) | missing | scanning landed (Batch 10); the claim link still needs placeholder people (Batch 4) | +| Attach a receipt or photo | Free (Pro adds 10 GB high-res storage) | exists | up to five photos and PDFs per expense, 10 MB each, photos shrunk to 2000 px on the device, each opens full screen with pinch and double tap zoom (Batch 10) | +| Notes on an expense | Free | exists | `expenses.notes`, 2000 chars | +| Categories with subcategories | Free | exists | 16 categories, 44 subcategories (taxi, flights, electricity, ...) stored as their own ids, rolled up with `parentCategory()` (Batch 9, [ADR 20](adr/0020-group-types-restorable-deletes-and-shared-notes.md)) | +| Auto-categorize from the description | unverified (users ask for it) | exists | the category follows the description until you pick one: the group's own history first, then a keyword list in all five languages ("Uber" is Transport) (Batch 9) | +| Expense date (past or future) | Free | exists | `spent_at` date picker | +| Expense in another currency | Free | exists | per-expense currency, FX snapshot into the group currency | +| New expense defaults to the last currency used in the group | Free | exists | starts in the currency of the last expense you added in that group (Batch 1) | +| Currency conversion of balances | Pro (converts a whole group, past expenses too, at market rates) | exists | Potluck converts every expense into the group currency at its date's ECB rate, so balances are always in one currency | +| Save a default split for a group | Pro | exists | `groups.default_split` via `set_default_split`; any member, from the expense form or group settings; new expenses start from it (Batch 1) | +| Add an expense from anywhere (quick add) | Free | exists | quick-add sheet with group picker | +| Link an expense to an event | n/a | exists | Potluck extra | +| Expense edit history | Free | exists | expense page lists who added, edited, deleted and restored it, and every field each edit changed (old and new values, payers and split too) (Batch 1) | +| Offline expense entry with sync | Free | partial | the installed PWA loads offline; writes need a connection | +| Comments on an expense | Free | exists | expense comments with realtime; text only, as in Splitwise (photos on comments: not applicable) | ### Groups @@ -401,10 +401,12 @@ Conflicts: Batch 1 (`expense-form.tsx` category picker: extract the picker into ### Batch 10: Receipts +Status: built on `feat/receipts` ([ADR 21](adr/0021-receipt-scanning-and-attachments.md), [receipts](receipts.md)): on-device scanning and up to five attachments per expense. Photos on comments are out of scope (Splitwise comments are text only). The diner claim link is not built yet. + Features: - Scan a receipt on the device (client-side OCR in a web worker, no server cost) to fill total, date and line items into the by-item editor. -- Several attachments per expense, and photos on comments. +- Several attachments per expense. - Stretch: a restaurant bill flow where diners claim their items from a link or QR code without an account (Splitwise's free US/UK itemize), built on Batch 4 placeholders. Acceptance criteria: diff --git a/docs/testing.md b/docs/testing.md index 2a93b13..d17d086 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -113,6 +113,7 @@ Each file runs in its own transaction and rolls back, so tests do not leak into | `24_recurring_editing.test.sql` | every 2 weeks (`recurring_step`, the job), `recurring_next_from` with the core cases, `update_recurring_expense` (who may, payers, adjustment and item splits, future copies only, every error code), departed members (details editable, money and resume refused until they are taken out), archived groups, `skip_recurring_expense`, `make_expense_recurring` (no back-charging, payers, items and percentages carried over), the heads-up job (09:00 local from UTC+14 to UTC-12 and UTC+5:30, late but before the post, late at night inbox only, who hears, their share, once per occurrence, paused, switched off, archived), who may edit (anyone involved), notes carried to posted copies, the anchor day kept across interval changes, the caller's own today, the job pausing and reporting a template naming someone who left | | `25_search_insights.test.sql` | `search_money` (who sees what across groups, every kind of match, filters, paging, input errors), `deleted_items` (order, who deleted, members only), `friend_activity` (only what involves both people), `expense_ledger` (shares add up, paid minus share is the balance, several payers, refunds, no cross-group rows), `spending_summary` (the same flat as the core test, net equals the balance, per viewer, converted into one currency at each date, through a common base, missing rates counted), trigram index use | | `28_groups.test.sql` | group type and trip dates (`create_group_v2`, the dates check, who may change them), deleting that waits 30 days (hidden from every policy and RPC, invites closed and reopened, no new expenses, members or notifications), `restore_group` by any member, `my_deleted_groups`, the purge job, cover storage (who uploads and lists, two-step sign in, deleted groups), the whiteboard (who reads and writes, version conflicts, the size cap, one activity line per sitting, archived and deleted groups); deleted groups leaving the timeline, plans, RSVPs, availability and the monthly summary, the delete notice and restore notice, the delete line in the feed, one-on-one tabs that cannot be deleted, covers under their own folder, debts in a circle, guest links closing and reopening, backups and imports leaving deleted groups alone | +| `29_expense_attachments.test.sql` | `expense_attachments` and `set_expense_attachments` (order, five at most, group folder only, uploaded files only, who may, deleted and archived, an edit right after the create logged, a create with files making exactly one entry and nothing when a file is refused), the `receipt_path` mirror for older clients, storage from other groups, anon and an unfinished two-step session, deleting files by uploader only, the orphan sweep | Every table in `public` needs tests for both who can and who cannot read and write it. A new RPC needs tests for each error code it can raise. @@ -168,6 +169,7 @@ Every test creates its own users and groups with unique names, so tests run in p | `a11y.spec.ts` | axe (WCAG 2.1 A and AA) on landing, home, group expenses, add expense, balances, event page, the signed out guest link page, Me, Friends (owing both ways) and the unsubscribe page: fails on serious or critical violations and on any sideways page scroll | | `account.spec.ts` | change password (wrong current refused, other devices signed out, a security email sent), sign out everywhere, two-step setup (codes only close with "I saved them") and sign in with a code (wrong code refused, API and Storage refused at aal1), turning it off clears the codes, a recovery code, five wrong recovery tries then a pause, email change through Mailpit links and calling one off, the language switching live (text and amounts) and following the account to a new device | | `recurring.spec.ts` | editing a repeating rent changes the series and leaves the posted copy; someone else sees it read only; making an expense repeat every 2 weeks from its page, then skipping the next one | +| `receipts.spec.ts` | scan the fixture receipt (`e2e/fixtures/receipt.png`) on the device, check and edit what it read, fill the by-item editor, add a second photo, save, open both full screen; group members can sign the files, an outsider cannot | | `group-life.spec.ts` | a trip with dates and a cover that a member and the signed out join page show, the invite as a QR code; notes typed by one member appear live for another and in the activity; a deleted group waits under Recently deleted and a member restores it; the category follows the description ("Uber" is Transport, a description used before keeps its category) | The landing audit emulates reduced motion: the hero demo is decorative and loops fade-ins, so the audit checks its settled state. From e283ac42a5253a38c2ebc01fb626449a3cd5f0b1 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:33:38 -0500 Subject: [PATCH 08/29] receipt parser: unit price lines, payment lines, VAT tables, items vs total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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> --- .../core/src/receipts/parse-receipt.test.ts | 147 ++++++++++++++++++ packages/core/src/receipts/parse-receipt.ts | 71 ++++++++- 2 files changed, 213 insertions(+), 5 deletions(-) diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index 1519eae..aed840f 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -120,6 +120,7 @@ describe('parseReceipt', () => { taxCents: 367, tipCents: 800, discountCents: null, + itemsMatch: 'exact', items: [ { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, { description: 'Caesar Salad', amountCents: 1150, quantity: 1 }, @@ -288,6 +289,7 @@ describe('parseReceipt', () => { tipCents: null, discountCents: null, items: [], + itemsMatch: null, }) const scan = parseReceipt('12\n****\nhello there', { currency: 'USD' }) expect(scan.totalCents).toBeNull() @@ -323,6 +325,151 @@ describe('parseReceipt', () => { }) }) +// A REWE till roll as the scanner reads it: unit price under an item, a card line, the VAT table. +const DE_REWE = `REWE Markt GmbH +Hauptstr. 12, 10115 Berlin +EUR +BIO VOLLMILCH 1,19 A +BROETCHEN 1,80 A + 2 Stk x 0,90 +KAESE GOUDA 3,49 A +BANANEN 1,65 A +WEIN ROT 6,99 B +SUMME EUR 15,12 +Geg. Karte EUR 15,12 +Steuer % Netto Steuer Brutto +A= 7,0% 7,61 0,52 8,13 +B= 19,0% 5,87 1,12 6,99 +10.11.2026 18:03 Bon-Nr 4711` + +describe('lines that are not items', () => { + it('reads a unit price line as the count of the item above it', () => { + const scan = parseReceipt(DE_REWE, { currency: 'EUR' }) + expect(items(scan.items)).toEqual([ + ['Bio Vollmilch', 119, 1], + ['Broetchen', 180, 2], + ['Kaese Gouda', 349, 1], + ['Bananen', 165, 1], + ['Wein Rot', 699, 1], + ]) + expect(scan.totalCents).toBe(1512) + expect(scan.taxCents).toBeNull() + expect(scan.itemsMatch).toBe('exact') + }) + + it.each([ + [ + 'English', + 'Bagels 4.50\n3 @ 1.50\nCoffee 2.00\nTotal 6.50', + 'USD', + [ + ['Bagels', 450, 3], + ['Coffee', 200, 1], + ], + ], + [ + 'French', + 'Croissant 3,60\n3 x 1,20\nThé 2,50\nTotal 6,10', + 'EUR', + [ + ['Croissant', 360, 3], + ['Thé', 250, 1], + ], + ], + [ + 'Spanish', + 'Cañas 6,00\n3 uds x 2,00\nPan 1,00\nTotal 7,00', + 'EUR', + [ + ['Cañas', 600, 3], + ['Pan', 100, 1], + ], + ], + [ + 'Portuguese', + 'Pão 2,40\n4 un x 0,60\nLeite 1,10\nTotal 3,50', + 'EUR', + [ + ['Pão', 240, 4], + ['Leite', 110, 1], + ], + ], + [ + 'a count lost to the scan', + 'Broetchen 1,80\n> stk x 0,90\nTotal 1,80', + 'EUR', + [['Broetchen', 180, 2]], + ], + [ + 'a unit price above its item', + 'Shop\n2 x 0,90\nBroetchen 1,80\nTotal 1,80', + 'EUR', + [['Broetchen', 180, 2]], + ], + ])('skips unit price lines (%s)', (_, text, currency, expected) => { + const scan = parseReceipt(text, { currency }) + expect(items(scan.items)).toEqual(expected) + expect(scan.itemsMatch).toBe('exact') + }) + + it.each([ + ['Card 7.00'], + ['VISA **** 4421 7.00'], + ['Cash 10.00'], + ['Change 3.00'], + ['Geg. Karte EUR 7,00'], + ['Geg. BAR EUR 10,00'], + ['Bar 10,00'], + ['Espèces 10,00'], + ['Carte bancaire 7,00'], + ['Efectivo 10,00'], + ['Tarjeta 7,00'], + ['Dinheiro 10,00'], + ['Troco 3,00'], + ['Kartenzahlung 7,00'], + ])('never takes a payment line for an item: %s', (line) => { + const scan = parseReceipt(`Shop\nTea 3.00\nCake 4.00\n${line}`, { currency: 'USD' }) + expect(scan.items.map((i) => i.description)).toEqual(['Tea', 'Cake']) + }) + + it.each([ + ['German', 'A= 7,0% 7,61 0,52 8,13'], + ['German, no equals sign', 'B 19,0% 5,87 1,12 6,99'], + ['French', 'TVA 10% 24,55 2,45 27,00'], + ['Spanish', 'IVA 21% 10,00 2,10 12,10'], + ['Portuguese', 'IVA 23% 8,13 1,87 10,00'], + ])('skips a VAT table row (%s)', (_, row) => { + const scan = parseReceipt(`Shop\nTea 3,00\nCake 4,00\n${row}\nTotal 7,00`, { currency: 'EUR' }) + expect(scan.items.map((i) => i.description)).toEqual(['Tea', 'Cake']) + expect(scan.taxCents).toBeNull() + expect(scan.itemsMatch).toBe('exact') + }) + + it('keeps a tax line whose rate has decimals', () => { + const scan = parseReceipt('Shop\nTea 3.00\nSales Tax 8.25% 0.25\nTotal 3.25', { + currency: 'USD', + }) + expect(scan.taxCents).toBe(25) + }) +}) + +describe('do the items match the total', () => { + const match = (text: string) => parseReceipt(text, { currency: 'USD' }).itemsMatch + + it('says exact, close (within 5%), off, or cannot tell', () => { + expect(match('Shop\nTea 5.00\nCake 5.00\nTotal 10.00')).toBe('exact') + expect(match('Shop\nTea 5.00\nCake 4.60\nTotal 10.00')).toBe('close') + expect(match('Shop\nTea 5.00\nCake 1.00\nTotal 10.00')).toBe('off') + expect(match('Shop\nTotal 10.00')).toBeNull() + }) + + it('drops a line dearer than the whole bill', () => { + const scan = parseReceipt('Shop\nGarlic 81.89\nRice 2.99\nTotal 76.50', { currency: 'USD' }) + expect(scan.items.map((i) => i.description)).toEqual(['Rice']) + expect(scan.itemsMatch).toBe('off') + }) +}) + describe('dates', () => { const dateOf = (line: string, extra: Partial[1]> = {}) => parseReceipt(line, { currency: 'EUR', ...extra }).date diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 639a649..fd270ad 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -25,6 +25,12 @@ export interface ReceiptScan { tipCents: number | null discountCents: number | null items: ReceiptItem[] + /** + * Whether the items (with tax, tip and discount) come to the total: `exact`, `close` (within + * 5%), `off`, or null when there is no total or no item to compare. Anything but `exact` means + * a line was misread or missed, and the review says so. + */ + itemsMatch: 'exact' | 'close' | 'off' | null } export interface ParseReceiptOptions { @@ -174,13 +180,27 @@ const DISCOUNT = const TOTAL = /\b(total|totale|totaal|gesamt\w*|summe|betrag|amount\s+due|balance\s+due|to\s+pay|a\s+pagar|zu\s+zahlen|montant|net\s+a\s+payer)\b/ const PAYMENT = - /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|tendered|tend|payment|paid|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|girocard|kartenzahlung|cambio|efectivo|tarjeta|troco|dinheiro|cartao|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b/ + /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b|\bbar\s+(eur|chf)\b/ const NOT_AN_ITEM = /\b(tel|phone|fax|www|http|order|table|server|guest|guests|cashier|kasse|receipt|invoice|facture|rechnung|ticket|recibo|qty|items|articles|artikel|anzahl)\b/ -type Kind = 'subtotal' | 'tax' | 'tip' | 'discount' | 'total' | 'payment' | 'item' +/** An amount with two decimals, as tax summary rows print them (not a rate like 8.25%). */ +const AMOUNTS = /\d[.,]\d{2}(?![\d%])/g + +/** + * A VAT summary row ("A= 7,0% 7,61 0,52 8,13", "B 19% 5,87 1,12 6,99"): a rate and several + * amounts, or a class letter and an equals sign. It restates the bill, so it is neither an item + * nor tax on top. + */ +function isTaxTable(key: string): boolean { + if (/^[a-z0-9]{1,2}\s*=/.test(key)) return true + return key.includes('%') && (key.match(AMOUNTS) ?? []).length >= 2 +} + +type Kind = 'subtotal' | 'tax' | 'tip' | 'discount' | 'total' | 'payment' | 'item' | 'table' function kindOf(key: string): Kind { + if (isTaxTable(key)) return 'table' if (SUBTOTAL.test(key)) return 'subtotal' if (TOTAL.test(key) && INCLUSIVE.test(key)) return 'total' if (TAX.test(key)) return 'tax' @@ -300,6 +320,12 @@ const QTY_AT_END = new RegExp( 'iu', ) const QTY_SUFFIX = /\s+[x×]\s*(\d{1,2})$/i +/** + * What is left of a unit price line once its price is cut off: "2 Stk x", "3 x", "2 @", "2 un x", + * "> stk x" (the count lost to the scan). It belongs to the item above it, or the one below. + */ +const QTY_LINE = + /^[^\p{L}\d]*(\d{1,3})?\s*(?:stk|st|pcs?|pz|ea|un|uds?|und|units?)?\.?\s*[x×@*]\s*$/iu const UNIT_AT_END = new RegExp(String.raw`\s+@\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, 'u') function itemOf(rest: string): { description: string; quantity: number } { @@ -355,6 +381,9 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip let itemsEnd = lines.length const items: ReceiptItem[] = [] let pending: string | null = null + // The line the last item came from, and a unit price line still waiting for its item. + let lastItemLine = -2 + let pendingQty: { count: number | null; unit: number } | null = null for (const [index, line] of lines.entries()) { const key = keys[index]! @@ -365,6 +394,10 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip continue } const kind = kindOf(key) + if (kind === 'table') { + pending = null + continue + } largest = Math.max(largest ?? 0, price.cents) switch (kind) { case 'subtotal': @@ -394,14 +427,32 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip discount = (discount ?? 0) + price.cents break } + const qty = QTY_LINE.exec(price.rest) + if (qty) { + // "2 Stk x 0,90" under "BROETCHEN 1,80": a count for the item above, not an item. + const count = qty[1] ? Number(qty[1]) : null + const above = lastItemLine === index - 1 ? items.at(-1) : undefined + if (above && above.amountCents % price.cents === 0) { + above.quantity = count ?? above.amountCents / price.cents + } else { + pendingQty = { count, unit: price.cents } + } + break + } const read = itemOf(price.rest) const description = !/\p{L}{2}/u.test(read.description) && pending ? itemOf(pending).description : read.description - const quantity = read.quantity + // A unit price line printed above its item ("2 x 0,90" then "BROETCHEN 1,80"). + const quantity = + pendingQty && price.cents % pendingQty.unit === 0 + ? (pendingQty.count ?? price.cents / pendingQty.unit) + : read.quantity + pendingQty = null if (/\p{L}{2}/u.test(description) && price.cents > 0) { items.push({ description, amountCents: price.cents, quantity }) + lastItemLine = index } break } @@ -409,9 +460,12 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null } - const offered = items.slice(0, MAX_RECEIPT_ITEMS) - const itemsSum = offered.reduce((a, i) => a + i.amountCents, 0) if (total === null && largest !== null) total = largest + // A line dearer than the whole bill is a misread (a smudge read as 81.89). + const offered = items + .filter((i) => total === null || i.amountCents <= total) + .slice(0, MAX_RECEIPT_ITEMS) + const itemsSum = offered.reduce((a, i) => a + i.amountCents, 0) const base = subtotal ?? itemsSum // Tax, tip and discount are kept only when they make the receipt add up the way the expense // form adds it (items + tax + tip - discount). VAT already inside the prices is left out. @@ -427,6 +481,12 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip } } + let itemsMatch: ReceiptScan['itemsMatch'] = null + if (total !== null && total > 0 && offered.length > 0) { + const diff = Math.abs(itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) - total) + itemsMatch = diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' + } + return { merchant: findMerchant(lines), // A receipt that names its currency says where it was printed, which beats the viewer's @@ -443,5 +503,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip tipCents: tip, discountCents: discount, items: offered, + itemsMatch, } } From 96c878c551c542567988b1b227b36efed4575adc Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:42 -0500 Subject: [PATCH 09/29] one history entry for files sent with a new expense, and a daily orphan 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> --- packages/api/src/database.types.ts | 72 +++++++++++++++++++ supabase/config.toml | 3 + supabase/functions/receipts-sweep/index.ts | 21 ++++++ .../functions/receipts-sweep/sweep.test.ts | 42 +++++++++++ supabase/functions/receipts-sweep/sweep.ts | 36 ++++++++++ .../20261113173404_expense_attachments.sql | 38 ++++++++++ supabase/tests/01_schema.test.sql | 4 ++ .../tests/29_expense_attachments.test.sql | 40 ++++++++--- 8 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 supabase/functions/receipts-sweep/index.ts create mode 100644 supabase/functions/receipts-sweep/sweep.test.ts create mode 100644 supabase/functions/receipts-sweep/sweep.ts diff --git a/packages/api/src/database.types.ts b/packages/api/src/database.types.ts index 8c04fcb..c271c75 100644 --- a/packages/api/src/database.types.ts +++ b/packages/api/src/database.types.ts @@ -732,6 +732,59 @@ export type Database = { }, ] } + expense_attachments: { + Row: { + content_type: string + created_at: string + expense_id: string + group_id: string + path: string + position: number + uploaded_by: string | null + } + ComputedFields: never + Insert: { + content_type: string + created_at?: string + expense_id: string + group_id: string + path: string + position: number + uploaded_by?: string | null + } + Update: { + content_type?: string + created_at?: string + expense_id?: string + group_id?: string + path?: string + position?: number + uploaded_by?: string | null + } + Relationships: [ + { + foreignKeyName: 'expense_attachments_expense_id_fkey' + columns: ['expense_id'] + isOneToOne: false + referencedRelation: 'expenses' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'expense_attachments_group_id_fkey' + columns: ['group_id'] + isOneToOne: false + referencedRelation: 'groups' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'expense_attachments_uploaded_by_fkey' + columns: ['uploaded_by'] + isOneToOne: false + referencedRelation: 'profiles' + referencedColumns: ['id'] + }, + ] + } expense_items: { Row: { amount_cents: number @@ -2324,6 +2377,7 @@ export type Database = { }[] } notify_mfa_recovered: { Args: { target: string }; Returns: undefined } + orphan_receipts: { Args: { max_count?: number }; Returns: string[] } record_mfa_recover_attempt: { Args: { target: string }; Returns: boolean } remind_debt: { Args: { group_id: string; note?: string; user_id: string } @@ -2546,6 +2600,24 @@ export type Database = { } set_availability: { Args: { event_id: string; slots: Json }; Returns: number } set_default_split: { Args: { group_id: string; split: Json }; Returns: Json } + set_expense_attachments: { + Args: { attachments: Json; id: string } + Returns: { + content_type: string + created_at: string + expense_id: string + group_id: string + path: string + position: number + uploaded_by: string | null + }[] + SetofOptions: { + from: '*' + to: 'expense_attachments' + isOneToOne: false + isSetofReturn: true + } + } set_guest_availability: { Args: { code: string; name: string; slots: Json; token: string } Returns: { diff --git a/supabase/config.toml b/supabase/config.toml index d4875dc..4d89e91 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -443,6 +443,9 @@ verify_jwt = false [functions.fx-sync] verify_jwt = false +[functions.receipts-sweep] +verify_jwt = false + [functions.ics-feed] verify_jwt = false diff --git a/supabase/functions/receipts-sweep/index.ts b/supabase/functions/receipts-sweep/index.ts new file mode 100644 index 0000000..236ab36 --- /dev/null +++ b/supabase/functions/receipts-sweep/index.ts @@ -0,0 +1,21 @@ +// Deletes receipt files that no expense uses any more (saves that never finished, files removed +// by someone the bucket would not let delete them). Called daily by pg_cron through the +// `invoke_edge_function` hook with the shared secret; deletes go through the Storage API so the +// stored objects go too (ADR 21, docs/receipts.md). +import { adminClient } from '../_shared/admin.ts' +import { hasEdgeSecret, json } from '../_shared/http.ts' +import { sweepOrphans } from './sweep.ts' + +Deno.serve(async (req) => { + if (!hasEdgeSecret(req)) return json({ error: 'forbidden' }, 403) + const admin = adminClient() + try { + const result = await sweepOrphans({ + rpc: (fn, args) => admin.rpc(fn, args), + remove: (paths) => admin.storage.from('receipts').remove(paths), + }) + return json(result) + } catch (error) { + return json({ error: String((error as { message?: string })?.message ?? error) }, 500) + } +}) diff --git a/supabase/functions/receipts-sweep/sweep.test.ts b/supabase/functions/receipts-sweep/sweep.test.ts new file mode 100644 index 0000000..615f4cf --- /dev/null +++ b/supabase/functions/receipts-sweep/sweep.test.ts @@ -0,0 +1,42 @@ +// Run with `pnpm test:functions` (Node's test runner; the module has no Deno imports). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { BATCH, sweepOrphans, type SweepClient } from './sweep.ts' + +function client(paths: string[] | null, { listError = null as unknown, failBatch = -1 } = {}) { + const removed: string[][] = [] + const fake: SweepClient = { + rpc: async () => ({ data: paths, error: listError }), + remove: async (batch) => { + removed.push(batch) + return { error: removed.length - 1 === failBatch ? { message: 'boom' } : null } + }, + } + return { fake, removed } +} + +test('deletes every orphan, a hundred at a time', async () => { + const paths = Array.from({ length: 250 }, (_, i) => `g/${i}.jpg`) + const { fake, removed } = client(paths) + assert.deepEqual(await sweepOrphans(fake), { removed: 250, failed: 0 }) + assert.deepEqual( + removed.map((b) => b.length), + [BATCH, BATCH, 50], + ) +}) + +test('does nothing when nothing is orphaned', async () => { + const { fake, removed } = client(null) + assert.deepEqual(await sweepOrphans(fake), { removed: 0, failed: 0 }) + assert.equal(removed.length, 0) +}) + +test('counts a failed batch and carries on', async () => { + const { fake } = client(['a', 'b'], { failBatch: 0 }) + assert.deepEqual(await sweepOrphans(fake), { removed: 0, failed: 2 }) +}) + +test('throws when the list cannot be read', async () => { + const { fake } = client(null, { listError: { message: 'denied' } }) + await assert.rejects(sweepOrphans(fake), { message: 'denied' }) +}) diff --git a/supabase/functions/receipts-sweep/sweep.ts b/supabase/functions/receipts-sweep/sweep.ts new file mode 100644 index 0000000..eb11494 --- /dev/null +++ b/supabase/functions/receipts-sweep/sweep.ts @@ -0,0 +1,36 @@ +// The receipts sweep without Deno: list orphaned files, delete them in batches. Tested by +// `pnpm test:functions` with a stand-in client. + +export interface SweepClient { + rpc( + fn: 'orphan_receipts', + args: { max_count: number }, + ): PromiseLike<{ data: string[] | null; error: unknown }> + remove(paths: string[]): PromiseLike<{ error: unknown }> +} + +/** Storage deletes at most this many objects per call. */ +export const BATCH = 100 + +/** + * Deletes receipt files no expense points at (the database decides which: older than a day, + * not a receipt, not an attachment). Returns how many went; a failed batch is reported and the + * next run tries again. + */ +export async function sweepOrphans( + client: SweepClient, + maxCount = 500, +): Promise<{ removed: number; failed: number }> { + const { data, error } = await client.rpc('orphan_receipts', { max_count: maxCount }) + if (error) throw error + const paths = data ?? [] + let removed = 0 + let failed = 0 + for (let i = 0; i < paths.length; i += BATCH) { + const batch = paths.slice(i, i + BATCH) + const result = await client.remove(batch) + if (result.error) failed += batch.length + else removed += batch.length + } + return { removed, failed } +} diff --git a/supabase/migrations/20261113173404_expense_attachments.sql b/supabase/migrations/20261113173404_expense_attachments.sql index 25a22e1..39b0a10 100644 --- a/supabase/migrations/20261113173404_expense_attachments.sql +++ b/supabase/migrations/20261113173404_expense_attachments.sql @@ -261,6 +261,19 @@ begin -- the same, so realtime tells everyone looking at the expense. This function logs the edit. perform set_config('potluck.expense_logged', eid::text, true); update public.expenses e set receipt_path = new_list -> 0 ->> 'path' where e.id = eid; + -- Files sent right after the caller created the expense are part of creating it: its + -- history keeps the one "added it" entry. + if exists ( + select 1 from public.activity a + where a.subject_id = eid and a.verb = 'expense.created' and a.actor_id = uid + and a.created_at > now() - interval '10 minutes' + ) and not exists ( + select 1 from public.activity a where a.subject_id = eid and a.verb <> 'expense.created' + ) then + return query + select a.* from public.expense_attachments a where a.expense_id = eid order by a.position; + return; + end if; perform private.log_activity(gid, uid, 'expense.updated', eid, jsonb_build_object( 'description', prev.description, 'amount_cents', prev.amount_cents, @@ -281,3 +294,28 @@ $$; revoke execute on function public.set_expense_attachments(uuid, jsonb) from public, anon; grant execute on function public.set_expense_attachments(uuid, jsonb) to authenticated, service_role; + +-- Orphan sweep ------------------------------------------------------------------------------------- + +-- Service role only (the receipts-sweep edge function): files in the receipts bucket older than a +-- day that no expense names as its receipt and no attachment row points at. They come from saves +-- that never finished (a closed tab) and from files removed by someone the bucket would not let +-- delete them. The function deletes them through the Storage API, which also removes the stored +-- object (deleting rows from storage.objects would leave the object behind). +create or replace function public.orphan_receipts(max_count integer default 500) +returns setof text +language sql stable security definer set search_path = '' as $$ + select o.name from storage.objects o + where o.bucket_id = 'receipts' + and o.created_at < now() - interval '1 day' + and not exists (select 1 from public.expenses e where e.receipt_path = o.name) + and not exists (select 1 from public.expense_attachments a where a.path = o.name) + order by o.created_at + limit greatest(1, least(coalesce(max_count, 500), 1000)); +$$; + +revoke execute on function public.orphan_receipts(integer) from public, anon, authenticated; +grant execute on function public.orphan_receipts(integer) to service_role; + +-- Daily, at a quiet hour. Without the Vault entries (local stack, CI) the hook does nothing. +select cron.schedule('receipts-sweep', '40 3 * * *', $$select private.invoke_edge_function('receipts-sweep')$$); diff --git a/supabase/tests/01_schema.test.sql b/supabase/tests/01_schema.test.sql index eb2757f..c7eeabc 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -12,6 +12,7 @@ begin; select plan(126); select plan(98); +select plan(100); select tables_are('public', array[ 'profiles', 'friendships', 'blocks', 'reports', @@ -78,6 +79,7 @@ select has_function('public', 'import_expenses', array['uuid', 'jsonb', 'text', select has_function('public', 'import_stage', array['uuid', 'uuid', 'jsonb'], 'import_stage(batch_id, group_id, rows)'); select has_function('public', 'export_my_data', array[]::text[], 'export_my_data'); select has_function('public', 'set_expense_attachments', array['uuid', 'jsonb'], 'set_expense_attachments'); +select has_function('public', 'orphan_receipts', array['integer'], 'orphan_receipts'); select has_function('public', 'settle_up', array['uuid', 'uuid', 'bigint', 'text', 'text'], 'settle_up'); select has_function('public', 'delete_settlement', array['uuid'], 'delete_settlement'); select has_function('public', 'simplified_debts', array['uuid'], 'simplified_debts'); @@ -267,6 +269,8 @@ select ok( select ok( exists (select 1 from cron.job where jobname = 'purge-import-batches' and schedule = '*/15 * * * *'), 'unfinished imports are cleared every quarter hour' + exists (select 1 from cron.job where jobname = 'receipts-sweep' and schedule = '40 3 * * *'), + 'orphaned receipt files are swept daily' ); -- Grants: clients never write money tables directly. diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql index 4dc757e..aa09d67 100644 --- a/supabase/tests/29_expense_attachments.test.sql +++ b/supabase/tests/29_expense_attachments.test.sql @@ -3,7 +3,7 @@ -- two-step sign in read none of them; set_expense_attachments and the receipt_path mirror for -- older clients. begin; -select plan(49); +select plan(51); select tests.create_user('ava'); select tests.create_user('ben'); @@ -83,14 +83,8 @@ select is( select is( (select count(*)::int from public.activity where subject_id = tests.id('e') and verb = 'expense.updated'), - 1, - 'one edit is logged' -); -select is( - (select data -> 'changes' -> 'attachments' from public.activity - where subject_id = tests.id('e') and verb = 'expense.updated'), - '{"from": 1, "to": 3}'::jsonb, - 'the edit says how many there were and are' + 0, + 'files sent right after creating the expense are part of creating it: one history entry' ); select tests.login('ava'); @@ -102,8 +96,8 @@ select lives_ok( reset role; select is( (select count(*)::int from public.activity where subject_id = tests.id('e') and verb = 'expense.updated'), - 1, - 'changes nothing and logs nothing' + 0, + 'the same list changes nothing and logs nothing' ); -- Paths are plain strings or {path} objects. @@ -130,6 +124,13 @@ select lives_ok( pg_temp.files('one.jpg', 'four.webp', 'two.png')), 'an admin can' ); +reset role; +select is( + (select data -> 'changes' -> 'attachments' from public.activity + where subject_id = tests.id('e') and verb = 'expense.updated' and actor_id = tests.uid('cleo')), + '{"from": 2, "to": 3}'::jsonb, + 'someone else changing the files is an edit that says how many there were and are' +); select tests.login('outsider'); select throws_ok( format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), pg_temp.files('two.png')), @@ -317,5 +318,22 @@ select is( select has_index('public', 'expense_attachments', 'expense_attachments_group_idx', 'group_id is indexed'); +-- The orphan sweep: files older than a day that nothing points at, for the service role only. +reset role; +insert into storage.objects (bucket_id, name, owner_id, created_at) values + ('receipts', tests.id('g') || '/abandoned.jpg', tests.uid('ben'), now() - interval '2 days'), + ('receipts', tests.id('g') || '/fresh.jpg', tests.uid('ben'), now()); +update storage.objects set created_at = now() - interval '2 days' +where bucket_id = 'receipts' and name = pg_temp.file('one.jpg'); +set local role service_role; +select is( + (select array_agg(split_part(p, '/', 2) order by p) from public.orphan_receipts() as p), + array['abandoned.jpg'], + 'old files nothing points at are swept; attached, receipt and new files are not' +); +reset role; +select tests.login('ava'); +select throws_ok($$ select public.orphan_receipts() $$, '42501', null, 'members cannot list orphans'); + select * from finish(); rollback; From bef41912b91bc07c7e051236a01230e1568de0dd Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:43 -0500 Subject: [PATCH 10/29] scan from the original photo, show it in the review, and let every price 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> --- apps/web/e2e/receipts.spec.ts | 25 +- .../features/expenses/receipts/image.test.ts | 33 +++ .../src/features/expenses/receipts/image.ts | 87 +++++- .../features/expenses/receipts/ocr.test.ts | 79 +++++ .../web/src/features/expenses/receipts/ocr.ts | 42 ++- .../expenses/receipts/receipts.test.tsx | 2 +- .../expenses/receipts/scan-fill.test.ts | 23 +- .../features/expenses/receipts/scan-fill.ts | 17 +- .../expenses/receipts/scan-receipt.tsx | 274 +++++++++++------- .../expenses/receipts/use-zoom-pan.test.tsx | 93 ++++++ .../expenses/receipts/use-zoom-pan.ts | 173 +++++++++++ .../expenses/receipts/zoomable-image.tsx | 58 ++++ packages/core/src/receipts/index.ts | 1 + packages/core/src/receipts/parse-receipt.ts | 33 ++- packages/i18n/src/de/money.ts | 6 + packages/i18n/src/en/money.ts | 5 + packages/i18n/src/es/money.ts | 6 + packages/i18n/src/fr/money.ts | 6 + packages/i18n/src/pt/money.ts | 5 + 19 files changed, 839 insertions(+), 129 deletions(-) create mode 100644 apps/web/src/features/expenses/receipts/image.test.ts create mode 100644 apps/web/src/features/expenses/receipts/ocr.test.ts create mode 100644 apps/web/src/features/expenses/receipts/use-zoom-pan.test.tsx create mode 100644 apps/web/src/features/expenses/receipts/use-zoom-pan.ts create mode 100644 apps/web/src/features/expenses/receipts/zoomable-image.tsx diff --git a/apps/web/e2e/receipts.spec.ts b/apps/web/e2e/receipts.spec.ts index e0afc40..043142e 100644 --- a/apps/web/e2e/receipts.spec.ts +++ b/apps/web/e2e/receipts.spec.ts @@ -36,7 +36,22 @@ test.describe('receipts', () => { await expect(review).toBeVisible({ timeout: 90_000 }) await expect(review.getByText('Corner Bistro')).toBeVisible() await expect(review.getByText(t('expenses.scan.items', { count: 3 }))).toBeVisible() - await expect(review).toContainText(formatMoney(2550, 'USD')) + await expect(review.getByRole('img', { name: t('expenses.scan.photoAlt') })).toBeVisible() + const priceOf = (label: string) => + review.getByRole('textbox', { name: `${t('expenses.itemized.itemAmount')}, ${label}` }) + await expect(priceOf(t('expenses.scan.total'))).toHaveValue('25.50') + // A price corrected against the photo is checked against the total again. + const warning = review.getByRole('status').filter({ hasText: t('expenses.scan.checkPrices') }) + await expect(warning).toHaveCount(0) + await priceOf('Lemonade').fill('4.75') + await expect(warning).toContainText( + t('expenses.scan.itemsOff', { + items: formatMoney(2575, 'USD'), + total: formatMoney(2550, 'USD'), + }), + ) + await priceOf('Lemonade').fill('4.50') + await expect(warning).toHaveCount(0) await review.getByRole('button', { name: t('expenses.scan.apply') }).click() await expect(page.getByRole('dialog')).toHaveCount(0) @@ -78,7 +93,13 @@ test.describe('receipts', () => { name: t('expenses.attachments.position', { number: 1, count: 2 }), }) await expect(viewer).toBeVisible() - await expect(viewer.getByRole('img')).toBeVisible() + const shown = viewer.getByRole('img') + await expect(shown).toBeVisible() + // Zoom in to read the small print, then back out. + await viewer.getByRole('button', { name: t('expenses.attachments.zoomIn') }).click() + await expect(shown).toHaveAttribute('style', /scale\(1\.5\)/) + await viewer.getByRole('button', { name: t('expenses.attachments.zoomOut') }).click() + await expect(shown).toHaveAttribute('style', /scale\(1\)/) await viewer.getByRole('button', { name: t('expenses.attachments.next') }).click() await expect( page.getByRole('dialog', { diff --git a/apps/web/src/features/expenses/receipts/image.test.ts b/apps/web/src/features/expenses/receipts/image.test.ts new file mode 100644 index 0000000..608a808 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/image.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { ocrImage, ocrScale, stretchGray } from './image' + +describe('the copy the reader sees', () => { + it('keeps every pixel of a normal photo, cuts a huge one down, enlarges a small one once', () => { + expect(ocrScale(1512, 2016, 1)).toBe(1) + expect(ocrScale(3024, 4032, 1)).toBeCloseTo(0.744, 3) + expect(ocrScale(760, 1000, 2)).toBe(2) + expect(ocrScale(1512, 2016, 2)).toBeCloseTo(1.19, 2) + expect(ocrScale(3024, 4032, 2)).toBeNull() + }) + + it('turns pixels gray and stretches faded print to black on white', () => { + // 100 pixels: one very dark, 98 mid grey, one very light. The 1% ends set black and white. + const pixels = new Uint8ClampedArray(100 * 4) + for (let p = 0; p < 100; p++) pixels.set([150, 150, 150, 255], p * 4) + pixels.set([40, 60, 80, 255], 0) + pixels.set([250, 250, 250, 255], 99 * 4) + stretchGray(pixels) + const gray = (p: number) => [pixels[p * 4], pixels[p * 4 + 1], pixels[p * 4 + 2]] + expect(gray(0)).toEqual([0, 0, 0]) + expect(gray(99)).toEqual([255, 255, 255]) + expect(new Set(gray(50)).size).toBe(1) + expect(pixels[50 * 4 + 3]).toBe(255) + }) + + it('reads a photo the browser cannot decode as it is, once', async () => { + // jsdom has no createImageBitmap. + const heic = new Blob(['x'], { type: 'image/heic' }) + expect(await ocrImage(heic, 1)).toBe(heic) + expect(await ocrImage(heic, 2)).toBeNull() + }) +}) diff --git a/apps/web/src/features/expenses/receipts/image.ts b/apps/web/src/features/expenses/receipts/image.ts index d7b6ae1..f620dac 100644 --- a/apps/web/src/features/expenses/receipts/image.ts +++ b/apps/web/src/features/expenses/receipts/image.ts @@ -14,7 +14,7 @@ export function fitWithin(width: number, height: number, max = MAX_EDGE) { const RESIZABLE = new Set(['image/jpeg', 'image/png', 'image/webp']) /** - * Shrinks a photo before it is uploaded or read: at most MAX_EDGE on its longest side, as JPEG + * Shrinks a photo before it is uploaded (OCR reads `ocrImage` instead): at most MAX_EDGE on its longest side, as JPEG * on white (a phone photo of a receipt goes from about 4 MB to about 400 KB). The camera's * orientation is applied, and re-encoding drops the photo's metadata (location included). * Anything the browser cannot decode (HEIC outside Safari, PDFs) is returned as it was. @@ -44,6 +44,91 @@ export async function prepareImage(file: File): Promise { } } +/** The OCR copy is cut down past this, so a 12 MP photo stays quick to read. */ +const OCR_MAX_EDGE = 3000 +/** A second, closer reading upscales small photos to about this. */ +const OCR_UPSCALE_EDGE = 2400 + +/** + * How much to scale a photo for OCR: the first pass keeps every pixel up to OCR_MAX_EDGE, the + * second (only when the first did not add up) enlarges a small photo up to twice. Null when a + * second pass would read the same pixels again. + */ +export function ocrScale(width: number, height: number, pass: 1 | 2): number | null { + const long = Math.max(width, height, 1) + if (pass === 1) return Math.min(1, OCR_MAX_EDGE / long) + return long < OCR_UPSCALE_EDGE ? Math.min(2, OCR_UPSCALE_EDGE / long) : null +} + +/** + * RGBA pixels to grayscale in place, with the darkest and lightest 1% stretched to black and + * white: faded thermal print and grey table light come out as dark ink on white paper. + */ +export function stretchGray(pixels: Uint8ClampedArray): void { + const histogram = new Uint32Array(256) + for (let i = 0; i < pixels.length; i += 4) { + const gray = Math.round((pixels[i]! * 299 + pixels[i + 1]! * 587 + pixels[i + 2]! * 114) / 1000) + pixels[i] = gray + histogram[gray]! += 1 + } + const cut = (pixels.length / 4) * 0.01 + let low = 0 + let high = 255 + for (let v = 0, seen = 0; v < 256; v++) { + seen += histogram[v]! + if (seen > cut) { + low = v + break + } + } + for (let v = 255, seen = 0; v >= 0; v--) { + seen += histogram[v]! + if (seen > cut) { + high = v + break + } + } + const span = Math.max(1, high - low) + for (let i = 0; i < pixels.length; i += 4) { + const value = ((pixels[i]! - low) * 255) / span + pixels[i] = pixels[i + 1] = pixels[i + 2] = value + } +} + +/** + * The copy of a photo the reader sees: from the original file (never the upload JPEG, whose + * compression smears small print), lossless PNG, grayscale with stretched contrast, scaled by + * `ocrScale`. A photo the browser cannot decode is read as it is on the first pass. + */ +export async function ocrImage(file: Blob, pass: 1 | 2): Promise { + try { + const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }) + const scale = ocrScale(bitmap.width, bitmap.height, pass) + if (scale === null) { + bitmap.close() + return null + } + const width = Math.max(1, Math.round(bitmap.width * scale)) + const height = Math.max(1, Math.round(bitmap.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) return pass === 1 ? file : null + context.fillStyle = '#ffffff' + context.fillRect(0, 0, width, height) + context.imageSmoothingQuality = 'high' + context.drawImage(bitmap, 0, 0, width, height) + bitmap.close() + const image = context.getImageData(0, 0, width, height) + stretchGray(image.data) + context.putImageData(image, 0, 0) + return await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) + } catch { + return pass === 1 ? file : null + } +} + /** Safari decodes HEIC photos; other browsers upload them untouched. */ function canDecodeHeic(): boolean { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent) diff --git a/apps/web/src/features/expenses/receipts/ocr.test.ts b/apps/web/src/features/expenses/receipts/ocr.test.ts new file mode 100644 index 0000000..d21fcd8 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/ocr.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ReaderUnavailableError, readReceiptText } from './ocr' + +const tess = vi.hoisted(() => ({ + texts: [] as string[], + fail: false, + seen: [] as unknown[], + terminate: vi.fn(async () => undefined), + logger: null as null | ((m: { status: string; progress: number }) => void), +})) + +vi.mock('tesseract.js', () => ({ + OEM: { LSTM_ONLY: 1 }, + createWorker: vi.fn( + async ( + _lang: string, + _oem: number, + options: { logger: (m: { status: string; progress: number }) => void }, + ) => { + if (tess.fail) throw new Error('network') + tess.logger = options.logger + options.logger({ status: 'loading tesseract core', progress: 0.5 }) + return { + recognize: vi.fn(async (image: unknown) => { + tess.seen.push(image) + tess.logger?.({ status: 'recognizing text', progress: 1 }) + return { data: { text: tess.texts.shift() ?? '' } } + }), + terminate: tess.terminate, + } + }, + ), +})) + +vi.mock('./image', () => ({ + ocrImage: vi.fn(async (_file: Blob, pass: 1 | 2) => (pass === 1 ? 'native' : 'closer')), +})) + +beforeEach(() => { + tess.texts = ['first', 'second'] + tess.fail = false + tess.seen = [] + tess.terminate.mockClear() +}) + +const photo = new Blob(['jpg'], { type: 'image/jpeg' }) + +describe('readReceiptText', () => { + it('reads once when the first reading is enough, and ends the worker', async () => { + const stages: string[] = [] + const texts = await readReceiptText(photo, { onProgress: (p) => stages.push(p.stage) }) + expect(texts).toEqual(['first']) + expect(tess.seen).toEqual(['native']) + expect(stages).toEqual(['loading', 'reading']) + expect(tess.terminate).toHaveBeenCalled() + }) + + it('reads again closer up when the first reading falls short', async () => { + const stages: string[] = [] + const texts = await readReceiptText(photo, { + enough: (text) => text === 'second', + onProgress: (p) => stages.push(p.stage), + }) + expect(texts).toEqual(['first', 'second']) + expect(tess.seen).toEqual(['native', 'closer']) + expect(stages).toContain('closer') + }) + + it('says the reader is unavailable when it cannot start', async () => { + tess.fail = true + await expect(readReceiptText(photo)).rejects.toBeInstanceOf(ReaderUnavailableError) + }) + + it('stops when aborted', async () => { + const controller = new AbortController() + controller.abort(new Error('stopped')) + await expect(readReceiptText(photo, { signal: controller.signal })).rejects.toThrow('stopped') + }) +}) diff --git a/apps/web/src/features/expenses/receipts/ocr.ts b/apps/web/src/features/expenses/receipts/ocr.ts index fc4c4af..cf74fe7 100644 --- a/apps/web/src/features/expenses/receipts/ocr.ts +++ b/apps/web/src/features/expenses/receipts/ocr.ts @@ -1,6 +1,8 @@ import type { Worker } from 'tesseract.js' +import { ocrImage } from './image' -export type ReadStage = 'loading' | 'reading' +/** Fetching the reader, reading, or reading again closer up. */ +export type ReadStage = 'loading' | 'reading' | 'closer' export interface ReadProgress { stage: ReadStage @@ -19,15 +21,28 @@ export class ReaderUnavailableError extends Error { * Reads the text on a photo, on the device (ADR 21). tesseract.js is imported only here, so it * is fetched the first time someone scans and never weighs on the app bundle; its worker, core * and English model come from our own origin (`ocr-assets.ts`) and the service worker keeps them, - * so later scans work offline. Each read starts a worker and ends it, so the memory it takes - * (around 100 MB) goes back as soon as the text is out. Aborting `signal` stops it. + * so later scans work offline. + * + * The reader sees a lossless grayscale copy of the original photo (`ocrImage`). When `enough` + * says the first reading falls short, it reads once more from an enlarged copy; the texts come + * back in order, for the caller to pick from. The worker is ended afterwards, so the memory it + * takes (around 100 MB) goes back at once. Aborting `signal` stops it. */ export async function readReceiptText( - image: Blob, - { onProgress, signal }: { onProgress?: (p: ReadProgress) => void; signal?: AbortSignal } = {}, -): Promise { + original: Blob, + { + onProgress, + signal, + enough = () => true, + }: { + onProgress?: (p: ReadProgress) => void + signal?: AbortSignal + enough?: (text: string) => boolean + } = {}, +): Promise { signal?.throwIfAborted() const base = new URL(`${__OCR_PATH__}/`, window.location.origin).href + let pass: 1 | 2 = 1 let worker: Worker try { const { createWorker, OEM } = await import('tesseract.js') @@ -39,7 +54,7 @@ export async function readReceiptText( workerBlobURL: false, logger: (m: { status: string; progress: number }) => onProgress?.({ - stage: m.status === 'recognizing text' ? 'reading' : 'loading', + stage: m.status !== 'recognizing text' ? 'loading' : pass === 1 ? 'reading' : 'closer', progress: m.progress, }), }) @@ -52,10 +67,17 @@ export async function readReceiptText( stop = () => reject(signal?.reason) signal?.addEventListener('abort', stop, { once: true }) }) + const texts: string[] = [] try { - signal?.throwIfAborted() - const { data } = await Promise.race([worker.recognize(image), stopped]) - return data.text + for (const next of [1, 2] as const) { + pass = next + const image = await Promise.race([ocrImage(original, next), stopped]) + if (!image) break + const { data } = await Promise.race([worker.recognize(image), stopped]) + texts.push(data.text) + if (enough(data.text)) break + } + return texts } finally { signal?.removeEventListener('abort', stop) await worker.terminate().catch(() => undefined) diff --git a/apps/web/src/features/expenses/receipts/receipts.test.tsx b/apps/web/src/features/expenses/receipts/receipts.test.tsx index 02772cf..0d91632 100644 --- a/apps/web/src/features/expenses/receipts/receipts.test.tsx +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -23,7 +23,7 @@ vi.mock('./ocr', async (original) => { onProgress?.({ stage: 'loading', progress: 0.5 }) onProgress?.({ stage: 'reading', progress: 0.4 }) if (ocr.fail) throw new real.ReaderUnavailableError(new Error('fetch failed')) - return ocr.text + return [ocr.text] }, ), } diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index 2606bf6..40b742a 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -1,7 +1,13 @@ import type { ReceiptScan } from '@potluck/core' import { describe, expect, it } from 'vitest' import { initialDraft, itemsTotal } from '../form/split-state' -import { defaultChoice, fillFromScan, scanFoundSomething, scanItemsTotal } from './scan-fill' +import { + bestScan, + defaultChoice, + fillFromScan, + scanFoundSomething, + scanItemsTotal, +} from './scan-fill' const scan = (over: Partial = {}): ReceiptScan => ({ merchant: "Joe's Diner", @@ -16,6 +22,7 @@ const scan = (over: Partial = {}): ReceiptScan => ({ { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, { description: 'Fries', amountCents: 1950, quantity: 1 }, ], + itemsMatch: 'exact', ...over, }) @@ -31,6 +38,20 @@ describe('scan review defaults', () => { ).toEqual({ name: false, date: false, total: false, items: false }) }) + it('leaves items unticked when they come nowhere near the total', () => { + expect(defaultChoice(scan({ itemsMatch: 'off' }), '').items).toBe(false) + expect(defaultChoice(scan({ itemsMatch: 'close' }), '').items).toBe(true) + }) + + it('picks the reading whose items come closest to its total', () => { + const off = scan({ itemsMatch: 'off' }) + const exact = scan({ itemsMatch: 'exact', merchant: 'Second look' }) + const empty = scan({ itemsMatch: null, totalCents: null, items: [] }) + expect(bestScan([off, exact]).merchant).toBe('Second look') + expect(bestScan([off, empty])).toBe(off) + expect(bestScan([empty])).toBe(empty) + }) + it('knows when a scan found nothing usable', () => { expect(scanFoundSomething(scan())).toBe(true) expect(scanFoundSomething(scan({ items: [] }))).toBe(true) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts index e15d4ac..bfc800e 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -25,10 +25,25 @@ export function defaultChoice(scan: ReceiptScan, description: string): ScanChoic name: !!scan.merchant && description.trim() === '', date: !!scan.date, total: scan.totalCents !== null, - items: scan.items.length > 0, + // Items that come nowhere near the total are offered but not ticked: the total alone is safer. + items: scan.items.length > 0 && scan.itemsMatch !== 'off', } } +const RANK = { exact: 3, close: 2, off: 1 } as const + +/** + * The best of several readings of one photo: the one whose items come closest to its total, + * then the one that found the most, then the first. + */ +export function bestScan(scans: ReceiptScan[]): ReceiptScan { + const score = (s: ReceiptScan) => + (s.itemsMatch ? RANK[s.itemsMatch] : 0) * 1000 + + (s.totalCents !== null ? 100 : 0) + + s.items.length + return scans.reduce((best, s) => (score(s) > score(best) ? s : best)) +} + /** Whether the scan found anything the form could use. */ export function scanFoundSomething(scan: ReceiptScan): boolean { return scan.totalCents !== null || scan.items.length > 0 diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index d96f37b..32ecc6d 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -1,4 +1,4 @@ -import { parseReceipt, receiptDateOrder, type ReceiptScan } from '@potluck/core' +import { parseReceipt, receiptDateOrder, receiptItemsMatch, type ReceiptScan } from '@potluck/core' import { formatDate, formatMoney, intlLocale, t } from '@potluck/i18n' import { Button, @@ -10,16 +10,18 @@ import { usePrefersReducedMotion, } from '@potluck/ui' import { motion } from 'motion/react' -import { ScanLine } from 'lucide-react' +import { AlertTriangle, ScanLine } from 'lucide-react' import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react' import { Help } from '@/features/help/help' import { todayIn } from '../format' +import { CompactMoneyInput } from '../form/compact-inputs' import type { SplitDraft } from '../form/split-state' -import { Money } from '../money' import { draftFromFile, type AttachmentDraft } from './attachments-state' import { prepareImage } from './image' import { ReaderUnavailableError, readReceiptText, type ReadStage } from './ocr' +import { ZoomableImage } from './zoomable-image' import { + bestScan, defaultChoice, fillFromScan, scanFoundSomething, @@ -85,20 +87,25 @@ export function ScanReceipt({ abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller - const photo = await prepareImage(picked) - const photoUrl = URL.createObjectURL(photo) - setState({ phase: 'reading', photo, url: photoUrl, stage: 'loading', progress: 0 }) + // The reader works from the original photo; the shrunk JPEG is only what gets uploaded. + const photoUrl = URL.createObjectURL(picked) + setState({ phase: 'reading', photo: picked, url: photoUrl, stage: 'loading', progress: 0 }) + const upload = prepareImage(picked) + const parse = (text: string) => + parseReceipt(text, { + currency, + dateOrder: receiptDateOrder(currency, intlLocale()), + today: todayIn(timeZone), + }) try { - const text = await readReceiptText(photo, { + const texts = await readReceiptText(picked, { signal: controller.signal, onProgress: ({ stage, progress }) => setState((s) => (s.phase === 'reading' ? { ...s, stage, progress } : s)), + enough: (text) => parse(text).itemsMatch === 'exact', }) - const scan = parseReceipt(text, { - currency, - dateOrder: receiptDateOrder(currency, intlLocale()), - today: todayIn(timeZone), - }) + const scan = bestScan((texts.length > 0 ? texts : ['']).map(parse)) + const photo = await upload setState( scanFoundSomething(scan) ? { @@ -207,6 +214,7 @@ export function ScanReceipt({ state.phase === 'review' ? t('expenses.scan.reviewHint') : t('expenses.scan.private') } closeLabel={t('common.close')} + size={state.phase === 'review' ? 'lg' : 'md'} footer={footer} > {state.phase === 'reading' && ( @@ -214,10 +222,12 @@ export function ScanReceipt({ )} {state.phase === 'review' && ( setState({ ...state, scan })} onChoice={(choice) => setState({ ...state, choice })} attach={state.attach} canAttach={canAttach} @@ -242,7 +252,13 @@ export function ScanReceipt({ function Reading({ url, stage, progress }: { url: string; stage: ReadStage; progress: number }) { const reduced = usePrefersReducedMotion() const percent = Math.round(progress * 100) - const label = stage === 'loading' ? t('expenses.scan.loading') : t('expenses.scan.reading') + const label = + stage === 'loading' + ? t('expenses.scan.loading') + : stage === 'closer' + ? t('expenses.scan.closer') + : t('expenses.scan.reading') + const reading = stage !== 'loading' return (
    @@ -264,9 +280,9 @@ function Reading({ url, stage, progress }: { url: string; stage: ReadStage; prog
    @@ -307,150 +323,196 @@ function Message({ url, children }: { url?: string; children: ReactNode }) { } /** - * What the receipt says, set like a till roll: one box per part, so a misread date or a wrong - * total can be left out while the rest is used. + * What the receipt says, set like a till roll beside the photo: one box per part, so a misread + * date or a wrong total can be left out, and every price can be corrected against the photo. */ function Review({ + url, scan, choice, formCurrency, timeZone, + onScan, onChoice, attach, canAttach, onAttach, }: { + url: string scan: ReceiptScan choice: ScanChoice formCurrency: string timeZone: string + onScan: (scan: ReceiptScan) => void onChoice: (choice: ScanChoice) => void attach: boolean canAttach: boolean onAttach: (attach: boolean) => void }) { const id = useId() + const locale = intlLocale() const currency = scan.currency ?? formCurrency const itemsTotal = useMemo(() => scanItemsTotal(scan), [scan]) - const off = scan.totalCents !== null && scan.items.length > 0 && itemsTotal !== scan.totalCents const toggle = (key: keyof ScanChoice) => (on: boolean | 'indeterminate') => onChoice({ ...choice, [key]: on === true }) + // A corrected price is checked against the total again straight away. + const edit = (patch: Partial) => { + const next = { ...scan, ...patch } + onScan({ ...next, itemsMatch: receiptItemsMatch(next) }) + } + const price = ( + label: string, + cents: number | null, + onChange: (cents: number) => void, + className?: string, + ) => ( + onChange(next ?? 0)} + aria-label={`${t('expenses.itemized.itemAmount')}, ${label}`} + className={cn('h-9 w-24 shrink-0 bg-card text-right', className)} + /> + ) const row = (part: keyof ScanChoice, label: ReactNode, value?: ReactNode, strong = false) => ( -
    +
    + {value}
    ) + const extras = ( + [ + ['tax', 'taxCents'], + ['tip', 'tipCents'], + ['discount', 'discountCents'], + ] as const + ).filter(([, key]) => scan[key] !== null) return ( -
    -
    - {scan.merchant && - row( - 'name', - {scan.merchant}, - )} - {scan.date && - row( - 'date', - t('expenses.scan.date'), - formatDate(scan.date, { timeZone, style: 'medium' }), - )} - {scan.items.length > 0 && ( -
    - {row('items', t('expenses.scan.items', { count: scan.items.length }))} -
      - {scan.items.map((item, i) => ( -
    • - - {item.quantity > 1 +
      + +
      +
      + {scan.merchant && + row( + 'name', + {scan.merchant}, + )} + {scan.date && + row( + 'date', + t('expenses.scan.date'), + + {formatDate(scan.date, { timeZone, style: 'medium' })} + , + )} + {scan.items.length > 0 && ( +
      + {row('items', t('expenses.scan.items', { count: scan.items.length }))} +
        + {scan.items.map((item, i) => { + const name = + item.quantity > 1 ? t('expenses.scan.quantity', { count: item.quantity, name: item.description, }) - : item.description} - - - - ))} - {( - [ - ['tax', scan.taxCents], - ['tip', scan.tipCents], - ['discount', scan.discountCents], - ] as const - ).map( - ([key, cents]) => - cents !== null && ( -
      • - {t(`expenses.itemized.${key}`)} - + : item.description + return ( +
      • + {name} + {price(name, item.amountCents, (cents) => + edit({ + items: scan.items.map((x, j) => + j === i ? { ...x, amountCents: cents } : x, + ), + }), + )}
      • - ), + ) + })} + {extras.map(([label, key]) => ( +
      • + {t(`expenses.itemized.${label}`)} + {price(t(`expenses.itemized.${label}`), scan[key], (cents) => + edit({ [key]: cents }), + )} +
      • + ))} +
      + {choice.items && ( +

      {t('expenses.scan.itemsHint')}

      )} -
    - {choice.items && ( -

    {t('expenses.scan.itemsHint')}

    - )} -
    - )} - {scan.totalCents !== null && ( -
    - {row( - 'total', - t('expenses.scan.total'), - , - true, - )} - {off && ( -

    +

    + )} + {scan.totalCents !== null && ( +
    + {row( + 'total', + t('expenses.scan.total'), + price( + t('expenses.scan.total'), + scan.totalCents, + (cents) => edit({ totalCents: cents }), + 'text-lg font-extrabold', + ), + true, + )} +
    + )} + {scan.currency && scan.currency !== formCurrency && ( +

    + {t('expenses.scan.currency')} + {scan.currency} +

    + )} +
    + {scan.itemsMatch !== null && scan.itemsMatch !== 'exact' && scan.totalCents !== null && ( +
    + +

    + {t('expenses.scan.itemsOff', { items: formatMoney(itemsTotal, currency), total: formatMoney(scan.totalCents, currency), })} -

    - )} + {' '} + {t('expenses.scan.checkPrices')} +

    )} - {scan.currency && scan.currency !== formCurrency && ( -

    - {t('expenses.scan.currency')} - {scan.currency} -

    + {canAttach && ( + onAttach(on === true)} + /> )}
    - {canAttach && ( - onAttach(on === true)} - /> - )}
    ) } diff --git a/apps/web/src/features/expenses/receipts/use-zoom-pan.test.tsx b/apps/web/src/features/expenses/receipts/use-zoom-pan.test.tsx new file mode 100644 index 0000000..e8a1ce2 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.test.tsx @@ -0,0 +1,93 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { clampView, zoomAt } from './use-zoom-pan' +import { ZoomableImage } from './zoomable-image' + +describe('zoom math', () => { + it('keeps a zoomed photo covering its frame', () => { + expect(clampView({ scale: 2, x: 500, y: -500 }, 200, 100)).toEqual({ scale: 2, x: 100, y: -50 }) + expect(clampView({ scale: 9, x: 0, y: 0 }, 200, 100).scale).toBe(4) + expect(clampView({ scale: 0.5, x: 30, y: 30 }, 200, 100)).toEqual({ scale: 1, x: 0, y: 0 }) + }) + + it('zooms into the point under the finger', () => { + // Zooming 2x into a point 50px right of center moves the photo 50px left. + expect(zoomAt({ scale: 1, x: 0, y: 0 }, 2, 50, 0)).toEqual({ scale: 2, x: -50, y: 0 }) + expect(zoomAt({ scale: 2, x: -50, y: 0 }, 1, 0, 0)).toEqual({ scale: 1, x: -25, y: 0 }) + }) +}) + +function frameOf() { + const img = screen.getByRole('img', { name: 'Receipt' }) + const frame = img.parentElement! + frame.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 400, height: 600, right: 400, bottom: 600 }) as DOMRect + return { img, frame } +} + +const pointer = (type: string, id: number, x: number, y: number, extra = {}) => + fireEvent[type as 'pointerDown'](frameOf().frame, { + pointerId: id, + clientX: x, + clientY: y, + pointerType: 'touch', + ...extra, + }) + +describe('ZoomableImage', () => { + it('zooms with the buttons and back out', () => { + render() + const { img } = frameOf() + expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + expect(img.style.transform).toContain('scale(1.5)') + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + expect(img.style.transform).toContain('scale(1)') + }) + + it('double clicks to zoom in and out', () => { + render() + const { img, frame } = frameOf() + fireEvent.doubleClick(frame, { clientX: 200, clientY: 300 }) + expect(img.style.transform).toContain('scale(2.5)') + fireEvent.doubleClick(frame, { clientX: 200, clientY: 300 }) + expect(img.style.transform).toContain('scale(1)') + }) + + it('double taps, pinches and drags on touch', () => { + render() + const { img } = frameOf() + // Double tap. + pointer('pointerDown', 1, 200, 300, { timeStamp: 1 }) + pointer('pointerUp', 1, 200, 300, { timeStamp: 10 }) + pointer('pointerDown', 1, 200, 300, { timeStamp: 100 }) + pointer('pointerUp', 1, 200, 300, { timeStamp: 120 }) + expect(img.style.transform).toContain('scale(2.5)') + // Drag the zoomed photo. + pointer('pointerDown', 1, 200, 300) + pointer('pointerMove', 1, 150, 300) + expect(img.style.transform).toContain('translate(-50px') + pointer('pointerUp', 1, 150, 300) + // Pinch in from 100px apart to 50px: back toward 1x. + pointer('pointerDown', 1, 150, 300) + pointer('pointerDown', 2, 250, 300) + pointer('pointerMove', 2, 200, 300) + pointer('pointerUp', 2, 200, 300) + pointer('pointerUp', 1, 150, 300) + expect(img.style.transform).toContain('scale(1.25)') + }) + + it('swipes to the next photo when it is not zoomed', () => { + const onSwipe = vi.fn() + render() + act(() => { + pointer('pointerDown', 1, 300, 300) + pointer('pointerMove', 1, 150, 310) + pointer('pointerUp', 1, 150, 310) + }) + expect(onSwipe).toHaveBeenCalledWith(1) + pointer('pointerDown', 1, 100, 300) + pointer('pointerUp', 1, 250, 300) + expect(onSwipe).toHaveBeenLastCalledWith(-1) + }) +}) diff --git a/apps/web/src/features/expenses/receipts/use-zoom-pan.ts b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts new file mode 100644 index 0000000..ef6f24b --- /dev/null +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts @@ -0,0 +1,173 @@ +import { + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from 'react' + +export interface View { + scale: number + x: number + y: number +} + +export const MAX_ZOOM = 4 +const TAP_ZOOM = 2.5 +const SWIPE = 60 +const DOUBLE_TAP_MS = 300 + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) + +/** Keeps a zoomed image covering its frame: it can be moved only as far as its overflow. */ +export function clampView(view: View, width: number, height: number): View { + const scale = clamp(view.scale, 1, MAX_ZOOM) + const maxX = ((scale - 1) * width) / 2 + const maxY = ((scale - 1) * height) / 2 + return { scale, x: clamp(view.x, -maxX, maxX), y: clamp(view.y, -maxY, maxY) } +} + +/** + * Zooming into the point at `(px, py)` (relative to the frame's center) by `scale`, from `view`: + * that point stays under the finger. + */ +export function zoomAt(view: View, scale: number, px: number, py: number): View { + const ratio = scale / view.scale + return { scale, x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio } +} + +/** + * Pinch and double tap to zoom, drag to move a zoomed photo, swipe sideways to go to the next + * one when it is not zoomed. Pointer events, so touch, pen and mouse behave the same; the + * buttons (`zoomIn`, `zoomOut`) cover the keyboard. + */ +export function useZoomPan({ onSwipe }: { onSwipe?: (step: 1 | -1) => void } = {}) { + const [view, setView] = useState({ scale: 1, x: 0, y: 0 }) + const [dragging, setDragging] = useState(false) + const pointers = useRef(new Map()) + const start = useRef<{ + view: View + x: number + y: number + distance: number + moved: boolean + } | null>(null) + const lastTap = useRef(0) + const frame = useRef(null) + + const size = () => { + const rect = frame.current?.getBoundingClientRect() + return { width: rect?.width ?? 0, height: rect?.height ?? 0, rect } + } + const settle = (next: View) => { + const { width, height } = size() + setView(clampView(next, width, height)) + } + const fromCenter = (clientX: number, clientY: number) => { + const { width, height, rect } = size() + return { + px: clientX - (rect?.left ?? 0) - width / 2, + py: clientY - (rect?.top ?? 0) - height / 2, + } + } + const toggleAt = (clientX: number, clientY: number) => { + if (view.scale > 1) return settle({ scale: 1, x: 0, y: 0 }) + const { px, py } = fromCenter(clientX, clientY) + settle(zoomAt(view, TAP_ZOOM, px, py)) + } + const distance = () => { + const [a, b] = [...pointers.current.values()] + return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0 + } + + const onPointerDown = (e: ReactPointerEvent) => { + frame.current = e.currentTarget + e.currentTarget.setPointerCapture?.(e.pointerId) + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }) + start.current = { view, x: e.clientX, y: e.clientY, distance: distance(), moved: false } + setDragging(true) + } + + const onPointerMove = (e: ReactPointerEvent) => { + if (!pointers.current.has(e.pointerId) || !start.current) return + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }) + const from = start.current + if (pointers.current.size >= 2 && from.distance > 0) { + from.moved = true + const scale = clamp((from.view.scale * distance()) / from.distance, 1, MAX_ZOOM) + const [a, b] = [...pointers.current.values()] + const { px, py } = fromCenter((a!.x + b!.x) / 2, (a!.y + b!.y) / 2) + settle(zoomAt(from.view, scale, px, py)) + return + } + const dx = e.clientX - from.x + const dy = e.clientY - from.y + if (Math.abs(dx) > 6 || Math.abs(dy) > 6) from.moved = true + if (from.view.scale > 1) settle({ ...from.view, x: from.view.x + dx, y: from.view.y + dy }) + else setView({ scale: 1, x: dx * 0.6, y: 0 }) + } + + const onPointerUp = (e: ReactPointerEvent) => { + const from = start.current + pointers.current.delete(e.pointerId) + if (pointers.current.size > 0) { + // One finger of a pinch lifted: carry on from here with the other. + const [rest] = [...pointers.current.values()] + start.current = { view, x: rest!.x, y: rest!.y, distance: 0, moved: true } + return + } + start.current = null + setDragging(false) + if (!from) return + const dx = e.clientX - from.x + const dy = e.clientY - from.y + if (from.view.scale === 1 && view.scale === 1) { + setView({ scale: 1, x: 0, y: 0 }) + if (Math.abs(dx) > SWIPE && Math.abs(dx) > Math.abs(dy) && onSwipe) { + onSwipe(dx < 0 ? 1 : -1) + return + } + } + if (!from.moved && e.pointerType !== 'mouse') { + const now = e.timeStamp + if (now - lastTap.current < DOUBLE_TAP_MS) { + lastTap.current = 0 + toggleAt(e.clientX, e.clientY) + } else { + lastTap.current = now + } + } + } + + const onDoubleClick = (e: ReactMouseEvent) => { + frame.current = e.currentTarget + toggleAt(e.clientX, e.clientY) + } + + const step = (factor: number) => settle(zoomAt(view, view.scale * factor, 0, 0)) + + const style: CSSProperties = { + transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`, + transition: dragging ? 'none' : 'transform 180ms ease-out', + touchAction: 'none', + } + + return { + view, + style, + frameProps: { + ref: (element: HTMLElement | null) => { + frame.current = element + }, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + onDoubleClick, + style: { touchAction: 'none' } as CSSProperties, + }, + zoomIn: () => step(1.5), + zoomOut: () => step(1 / 1.5), + reset: () => setView({ scale: 1, x: 0, y: 0 }), + } +} diff --git a/apps/web/src/features/expenses/receipts/zoomable-image.tsx b/apps/web/src/features/expenses/receipts/zoomable-image.tsx new file mode 100644 index 0000000..10632e8 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/zoomable-image.tsx @@ -0,0 +1,58 @@ +import { t } from '@potluck/i18n' +import { IconButton, cn } from '@potluck/ui' +import { ZoomIn, ZoomOut } from 'lucide-react' +import { MAX_ZOOM, useZoomPan } from './use-zoom-pan' + +/** + * A photo that pinches, double taps and drags to zoom, with buttons for the keyboard. Swiping + * sideways when it is not zoomed calls `onSwipe`. Key it by `src` so a new photo starts unzoomed. + */ +export function ZoomableImage({ + src, + alt, + onSwipe, + className, + imageClassName, +}: { + src: string + alt: string + onSwipe?: (step: 1 | -1) => void + className?: string + imageClassName?: string +}) { + const zoom = useZoomPan({ onSwipe }) + return ( +
    + {alt} + + } + disabled={zoom.view.scale <= 1} + onPointerDown={(e) => e.stopPropagation()} + onClick={zoom.zoomOut} + /> + } + disabled={zoom.view.scale >= MAX_ZOOM} + onPointerDown={(e) => e.stopPropagation()} + onClick={zoom.zoomIn} + /> + +
    + ) +} diff --git a/packages/core/src/receipts/index.ts b/packages/core/src/receipts/index.ts index 0c32698..8010385 100644 --- a/packages/core/src/receipts/index.ts +++ b/packages/core/src/receipts/index.ts @@ -3,6 +3,7 @@ export { parseReceipt, parseReceiptAmount, receiptDateOrder, + receiptItemsMatch, type ParseReceiptOptions, type ReceiptItem, type ReceiptScan, diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index fd270ad..cfb3d27 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -355,6 +355,25 @@ function itemOf(rest: string): { description: string; quantity: number } { } } +/** + * Whether the items, with tax and tip added and the discount taken off, come to the total: + * `exact`, `close` (within 5% of it), `off`, or null when there is no total or no item. Run again + * after the person corrects a price. + */ +export function receiptItemsMatch( + scan: Pick, +): ReceiptScan['itemsMatch'] { + const total = scan.totalCents + if (total === null || total <= 0 || scan.items.length === 0) return null + const sum = + scan.items.reduce((a, i) => a + i.amountCents, 0) + + (scan.taxCents ?? 0) + + (scan.tipCents ?? 0) - + (scan.discountCents ?? 0) + const diff = Math.abs(sum - total) + return diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' +} + // The parser ----------------------------------------------------------------------------------- /** @@ -481,12 +500,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip } } - let itemsMatch: ReceiptScan['itemsMatch'] = null - if (total !== null && total > 0 && offered.length > 0) { - const diff = Math.abs(itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) - total) - itemsMatch = diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' - } - return { merchant: findMerchant(lines), // A receipt that names its currency says where it was printed, which beats the viewer's @@ -503,6 +516,12 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip tipCents: tip, discountCents: discount, items: offered, - itemsMatch, + itemsMatch: receiptItemsMatch({ + totalCents: total, + taxCents: tax, + tipCents: tip, + discountCents: discount, + items: offered, + }), } } diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index a0b90f5..6480945 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -427,6 +427,8 @@ export const expenses = { openPdf: 'PDF öffnen', partial: 'Gespeichert, aber die Dateien wurden nicht angehängt. Bearbeite die Ausgabe, um sie erneut hinzuzufügen.', + zoomIn: 'Vergrößern', + zoomOut: 'Verkleinern', }, scan: { button: 'Beleg scannen', @@ -459,6 +461,10 @@ export const expenses = { attachOnly: 'Nur das Foto anhängen', again: 'Anderes Foto versuchen', applied: 'Vom Beleg übernommen. Prüf alles und wähl, wer was hatte.', + closer: 'Wir schauen genauer hin', + checkPrices: + 'Vergleich die Preise mit dem Foto, oder nimm den Haken bei den Posten raus, um nur die Summe zu nehmen.', + photoAlt: 'Der gescannte Beleg', }, } satisfies Translation diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index 98ff848..b0c2e47 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -427,6 +427,8 @@ export const expenses = { openOriginal: 'Open the original', openPdf: 'Open the PDF', partial: 'Saved, but the files did not attach. Edit the expense to add them again.', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', }, /** Reading a receipt photo on the device to fill in the form. */ scan: { @@ -458,6 +460,9 @@ export const expenses = { attachOnly: 'Just attach the photo', again: 'Try another photo', applied: 'Filled in from the receipt. Check it, then pick who had what.', + closer: 'Taking a closer look', + checkPrices: 'Check the prices against the photo, or untick the items to use the total only.', + photoAlt: 'The receipt you scanned', }, } as const diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index d93bc03..9d17764 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -420,6 +420,8 @@ export const expenses = { openPdf: 'Abrir el PDF', partial: 'Guardado, pero los archivos no se adjuntaron. Edita el gasto para añadirlos otra vez.', + zoomIn: 'Acercar', + zoomOut: 'Alejar', }, scan: { button: 'Escanear un recibo', @@ -452,6 +454,10 @@ export const expenses = { attachOnly: 'Solo adjuntar la foto', again: 'Probar con otra foto', applied: 'Rellenado con el recibo. Revísalo y elige quién tomó qué.', + closer: 'Mirando más de cerca', + checkPrices: + 'Compara los precios con la foto, o desmarca los artículos para usar solo el total.', + photoAlt: 'El recibo que escaneaste', }, } satisfies Translation diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index 778bbe1..252657b 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -427,6 +427,8 @@ export const expenses = { openPdf: 'Ouvrir le PDF', partial: "C'est enregistré, mais les fichiers ne sont pas passés. Modifie la dépense pour les rajouter.", + zoomIn: 'Agrandir', + zoomOut: 'Réduire', }, scan: { button: 'Scanner un reçu', @@ -460,6 +462,10 @@ export const expenses = { attachOnly: 'Joindre seulement la photo', again: 'Essayer une autre photo', applied: 'Rempli depuis le reçu. Vérifie, puis choisis qui a pris quoi.', + closer: 'On regarde de plus près', + checkPrices: + 'Compare les prix avec la photo, ou décoche les articles pour garder seulement le total.', + photoAlt: 'Le reçu que tu as scanné', }, } satisfies Translation diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index f1c5ec6..ba130c4 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -420,6 +420,8 @@ export const expenses = { openPdf: 'Abrir o PDF', partial: 'Salvo, mas os arquivos não foram anexados. Edite a despesa para adicioná-los de novo.', + zoomIn: 'Aproximar', + zoomOut: 'Afastar', }, scan: { button: 'Escanear um recibo', @@ -451,6 +453,9 @@ export const expenses = { attachOnly: 'Só anexar a foto', again: 'Tentar outra foto', applied: 'Preenchido com o recibo. Confira e escolha quem pegou o quê.', + closer: 'Olhando mais de perto', + checkPrices: 'Confira os preços com a foto, ou desmarque os itens para usar só o total.', + photoAlt: 'O recibo que você escaneou', }, } satisfies Translation From a1d48065db7e56f73e0b9fabd61b518b0cf62616 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:43 -0500 Subject: [PATCH 11/29] zoom and swipe in the photo viewer, and name PDFs the same everywhere 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> --- .../features/expenses/expense-detail.test.tsx | 6 +++- .../expenses/receipts/attachment-gallery.tsx | 34 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/web/src/features/expenses/expense-detail.test.tsx b/apps/web/src/features/expenses/expense-detail.test.tsx index 4481d83..5f6eede 100644 --- a/apps/web/src/features/expenses/expense-detail.test.tsx +++ b/apps/web/src/features/expenses/expense-detail.test.tsx @@ -374,7 +374,11 @@ describe('ExpenseDetail', () => { await screen.findByRole('heading', { name: t('expenses.attachments.label') }), ).toBeInTheDocument() expect( - await screen.findByRole('link', { name: t('expenses.receiptUpload.pdf') }), + await screen.findByRole('link', { + name: t('expenses.attachments.open', { + name: t('expenses.attachments.pdf', { number: 2 }), + }), + }), ).toHaveAttribute('href', `https://files.test/${GROUP_ID}/b.pdf`) const photo = t('expenses.attachments.photo', { number: 1 }) diff --git a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx index 64da008..eb25478 100644 --- a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -6,13 +6,14 @@ import { ChevronLeft, ChevronRight, ExternalLink, FileText } from 'lucide-react' import { useEffect, useState } from 'react' import { supabase } from '@/lib/supabase' import { attachmentName } from './attachments-state' +import { ZoomableImage } from './zoomable-image' const asDraft = (a: ExpenseAttachment) => ({ contentType: a.content_type }) const isPdf = (a: ExpenseAttachment) => a.content_type === 'application/pdf' /** - * Receipts and photos on the expense page. Each opens full screen; arrow keys and the buttons - * move between them. Links are signed for an hour, for members of the group only. + * Receipts and photos on the expense page. Each opens full screen, where it pinches and double + * taps to zoom; swipes, arrow keys and the buttons move between them. Links are signed for an hour, for members of the group only. */ export function AttachmentGallery({ expenseId, @@ -110,10 +111,11 @@ function Thumb({ href={url} target="_blank" rel="noreferrer" + aria-label={t('expenses.attachments.open', { name })} className="flex h-full min-h-24 items-center gap-3 rounded-card border border-line bg-card p-4 font-semibold hover:border-line-strong" > - {t('expenses.receiptUpload.pdf')} + {name} ) : ( @@ -210,19 +212,21 @@ function Viewer({
    } > -
    - {isPdf(file) ? ( + {isPdf(file) ? ( +
    - ) : url ? ( - {t('expenses.attachments.alt', - ) : ( - - )} -
    +
    + ) : url ? ( + 1 ? onMove : undefined} + className="h-[calc(100dvh-11rem)] min-h-[50dvh] w-full rounded-card bg-sunken" + /> + ) : ( + + )} ) } From dddea8496e78618a1ea880bc0787ca13407850a8 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:44 -0500 Subject: [PATCH 12/29] reuse uploads on a retried save, delete the ones nothing kept 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> --- .../form/expense-form-receipts.test.tsx | 50 +++++++++++++++++-- .../features/expenses/form/expense-form.tsx | 21 ++++++-- .../expenses/receipts/attachments-state.ts | 26 ++++++---- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx index 82e4b65..10d8070 100644 --- a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx @@ -29,13 +29,13 @@ vi.mock('@potluck/api', async (importOriginal) => { }) vi.mock('../receipts/ocr', async (original) => ({ ...(await original()), - readReceiptText: vi.fn( - async () => `TAQUERIA SOL + readReceiptText: vi.fn(async () => [ + `TAQUERIA SOL 11/08/2026 Tacos al pastor 12.00 Horchata 4.00 TOTAL 16.00`, - ), + ]), })) const photo = (name = 'p.jpg') => new File(['jpg'], name, { type: 'image/jpeg' }) @@ -139,6 +139,50 @@ describe('expense form files', () => { expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() }) + it('reuses uploads when a failed save is retried, and deletes them if the form closes', async () => { + vi.mocked(createExpense).mockRejectedValueOnce({ message: 'fx_rate_unavailable' }) + const onDone = vi.fn() + const view = renderWithClient( + {}} />, + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) + await addFiles([photo('a.jpg'), photo('b.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(vi.mocked(createExpense)).toHaveBeenCalledTimes(1)) + expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) + const [first, second] = vi.mocked(createExpense).mock.calls + expect(second![1].receipt_path).toBe(first![1].receipt_path) + view.unmount() + expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([first![1].receipt_path]), + ) + }) + + it('deletes uploads for a save that never went through when the form closes', async () => { + vi.mocked(createExpense).mockRejectedValueOnce({ message: 'fx_rate_unavailable' }) + const view = renderWithClient( + {}} onClose={() => {}} />, + ) + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) + await addFiles([photo('a.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(vi.mocked(createExpense)).toHaveBeenCalledTimes(1)) + const path = vi.mocked(createExpense).mock.calls[0]![1].receipt_path + view.unmount() + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), [path]) + }) + it('tells the person when the expense saved but its files did not', async () => { vi.mocked(setExpenseAttachments).mockRejectedValueOnce({ message: 'attachment_not_found' }) const onDone = vi.fn() diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index 7508a7a..4b7e656 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -6,6 +6,7 @@ import { MAX_ATTACHMENTS, moneyKeys, moneyQueries, + removeReceiptFiles, setDefaultSplit, updateExpense, updateRecurring, @@ -50,7 +51,7 @@ import { Money } from '../money' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { CalendarHeart, Repeat } from 'lucide-react' -import { useId, useMemo, useState, type FormEvent, type ReactNode } from 'react' +import { useEffect, useId, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useUserId } from '@/lib/auth' import { supabase } from '@/lib/supabase' import { openMemberIds, templateAsExpense } from '@/features/recurring/template' @@ -286,6 +287,15 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { }) const [attachments, setAttachments] = useState(expense ? null : []) const fileDrafts = attachments ?? draftsFromStored(storedFiles.data ?? []) + // Files uploaded for a save that has not gone through yet: reused on a retry, deleted if the + // form closes without saving. + const uploaded = useRef(new Map()) + useEffect(() => { + const pending = uploaded.current + return () => { + if (pending.size > 0) void removeReceiptFiles(supabase, [...pending.values()]) + } + }, []) const [recurrence, setRecurrence] = useState( template?.recurrence ?? (preset ? 'monthly' : 'none'), ) @@ -383,21 +393,26 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { let paths: string[] = [] if (attachments && filesChanged) { try { - paths = await uploadDrafts(supabase, group.id, attachments) + paths = await uploadDrafts(supabase, group.id, attachments, uploaded.current) } catch { throw new Error('receipt_upload_failed') } } // The first file also rides along as receipt_path on a new expense; the rest are set once - // it exists (ADR 21). + // it exists (ADR 21). Uploads the expense does not end up with are deleted. const withFiles = async (saved: Awaited>) => { + let kept = expense ? stored.map((a) => a.path) : paths.slice(0, 1) if (filesChanged && (expense || paths.length > 1)) { try { await saveAttachments(supabase, saved.id, paths, stored) + kept = paths } catch { toast.error(t('expenses.attachments.partial')) } } + const unused = [...uploaded.current.values()].filter((p) => !kept.includes(p)) + uploaded.current.clear() + void removeReceiptFiles(supabase, unused) return saved } const payload: ExpensePayload = { diff --git a/apps/web/src/features/expenses/receipts/attachments-state.ts b/apps/web/src/features/expenses/receipts/attachments-state.ts index df07bc5..6af2e64 100644 --- a/apps/web/src/features/expenses/receipts/attachments-state.ts +++ b/apps/web/src/features/expenses/receipts/attachments-state.ts @@ -60,21 +60,29 @@ export function attachmentsChanged( return drafts.length !== before.length || drafts.some((d, i) => d.path !== before[i]) } -/** Uploads the new files in order and returns every path, in the form's order. */ +/** + * Uploads the new files and returns every path, in the form's order. `uploaded` remembers what + * already went up (draft key to path), so a retry after a failed save sends nothing twice; the + * form deletes whatever is left in it when it closes without saving. + */ export async function uploadDrafts( client: PotluckClient, groupId: string, drafts: readonly AttachmentDraft[], + uploaded: Map = new Map(), ): Promise { return Promise.all( - drafts.map((d) => - d.path !== undefined - ? d.path - : uploadReceipt(client, groupId, d.file, { - extension: receiptExtension(d.file), - contentType: d.contentType, - }), - ), + drafts.map(async (d) => { + if (d.path !== undefined) return d.path + const done = uploaded.get(d.key) + if (done) return done + const path = await uploadReceipt(client, groupId, d.file, { + extension: receiptExtension(d.file), + contentType: d.contentType, + }) + uploaded.set(d.key, path) + return path + }), ) } From 8136826d3bb57383963b02c99ff46f3037829804 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:44 -0500 Subject: [PATCH 13/29] receipts docs: the reading, cleanup and sweep, and comments stay text 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> --- .../0021-receipt-scanning-and-attachments.md | 11 ++++++---- docs/deployment.md | 14 ++++++------- docs/sequences.md | 21 ++++++++++++------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index 1401dda..308b507 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -9,17 +9,20 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision -- **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. +- **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy and keeps the better reading. - **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. -- **A scan is a suggestion.** The review sheet shows each part with its own box. "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before reading and uploading, which also drops their metadata. -- **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. +- **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before reading and uploading, which also drops their metadata. +- **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. Files sent right after the caller created the expense are part of creating it, so its history keeps one entry. - **`receipt_path` stays the first attachment.** The new client sends the first file as `receipt_path` on create and sets the rest with `set_expense_attachments`; the function keeps `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. +- **No orphaned files.** Uploads for a save that fails are reused when it is retried and deleted when the form closes without saving. A daily job (`receipts-sweep`) deletes what is left: files older than a day that no expense or attachment names, listed by `orphan_receipts` and removed by an edge function through the Storage API, so the stored objects go too. + ## Consequences - No per-scan cost and no receipt leaves the device for reading. The first scan downloads about 7 MB (worker, one core build, model); a device without that cache and without a connection cannot scan and says so. - Accuracy is the open source engine's: clean printed till receipts read well, crumpled or handwritten ones do not, and non-Latin scripts are not read (English model only). The review step and the boxes keep a bad read from becoming a bad expense. - `tesseract.js` and its core are in `package.json` with exact versions. Upgrading changes the `/ocr//` path, so an old cache is never mixed with a new worker. - Two writes on save (the expense, then its files) are not atomic. If the second fails, the expense is saved with its first file and the person is told to add the rest by editing. -- Files removed by someone who did not upload them, and is not an admin, stay in storage (the bucket's delete rule), unreferenced but readable by the group. +- Files removed by someone who did not upload them, and is not an admin, stay in storage (the bucket's delete rule) until the next sweep, unreferenced but readable by the group. +- A second reading costs another second or two, only when the first one does not add up. Measured on 18 rendered receipts (six layouts, clean, mild and phone photo): items matching the total went from 5 of 18 (JPEG copy, old parser) to 15 of 18; the misses are blurred, tilted photos. - The parser is heuristics. New receipt formats are new test cases in `parse-receipt.test.ts`. diff --git a/docs/deployment.md b/docs/deployment.md index 8d67567..0b30534 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -85,13 +85,13 @@ npx supabase secrets set --project-ref \ APP_URL=https://potluck.fermulate.com ``` -| Secret | Read by | Notes | -| ------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | -| `VAPID_PUBLIC_KEY` | `notify` | same value as the `VITE_VAPID_PUBLIC_KEY` variable | -| `VAPID_PRIVATE_KEY` | `notify` | without the key pair `notify` answers `{ skipped: 'vapid_not_configured' }` | -| `VAPID_SUBJECT` | `notify` | a `mailto:` or https URL for push services to contact. Defaults to `APP_URL` | -| `EDGE_SECRET` | `notify`, `fx-sync` | shared with the database. Must equal the Vault `edge_secret` | -| `APP_URL` | `notify`, `ics-feed` | base for links in push messages and calendar events. Defaults to https://potluck.fermulate.com | +| Secret | Read by | Notes | +| ------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `VAPID_PUBLIC_KEY` | `notify` | same value as the `VITE_VAPID_PUBLIC_KEY` variable | +| `VAPID_PRIVATE_KEY` | `notify` | without the key pair `notify` answers `{ skipped: 'vapid_not_configured' }` | +| `VAPID_SUBJECT` | `notify` | a `mailto:` or https URL for push services to contact. Defaults to `APP_URL` | +| `EDGE_SECRET` | `notify`, `fx-sync`, `receipts-sweep` | shared with the database. Must equal the Vault `edge_secret` | +| `APP_URL` | `notify`, `ics-feed` | base for links in push messages and calendar events. Defaults to https://potluck.fermulate.com | `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` are provided by the platform. Generate VAPID keys with `npx web-push generate-vapid-keys`. diff --git a/docs/sequences.md b/docs/sequences.md index 89e70aa..e1bb265 100644 --- a/docs/sequences.md +++ b/docs/sequences.md @@ -347,7 +347,7 @@ sequenceDiagram participant DB as DB P->>W: Scan a receipt, pick or take a photo - W->>W: prepareImage: at most 2000 px, JPEG on white + W->>W: preview and OCR from the original photo,
    prepareImage (2000 px JPEG) only for upload W->>W: import("tesseract.js") (lazy chunk) W->>O: createWorker(eng, LSTM only) from /ocr// O->>SW: worker.min.js, core .wasm.js, eng.traineddata.gz @@ -356,10 +356,14 @@ sequenceDiagram else later scans, online or not SW-->>O: from cache end - O-->>W: text, then the worker is terminated - W->>C: parseReceipt(text, currency, date order, today) - C-->>W: merchant, date, currency, total, items, tax, tip, discount - W->>P: review sheet, one box per part + O-->>W: text from a lossless grayscale copy (ocrImage pass 1) + W->>C: parseReceipt(text): itemsMatch? + opt items do not come to the total + W->>O: read again from an enlarged copy (pass 2), keep the better reading + end + O-->>W: worker terminated + C-->>W: merchant, date, currency, total, items, tax, tip, discount, itemsMatch + W->>P: review sheet: the photo, one box per part, editable prices, a warning if off P->>W: Fill in the expense W->>W: description, date, amount, by-item draft (everyone on every line),
    photo added to the attachments (nothing saved) P->>W: pick who had what, add more files, Save @@ -372,13 +376,16 @@ sequenceDiagram W->>DB: rpc set_expense_attachments(id, paths in order) DB->>DB: group share lock, member, own expense or admin,
    paths in the group folder and uploaded, five at most DB->>DB: rewrite expense_attachments, receipt_path = first,
    activity expense.updated {attachments: {from, to}} - W->>S: remove files that left the list (uploader or admin only) + W->>S: remove files that left the list, and unused uploads (uploader or admin only) end + Note over W,S: a failed save keeps its uploads for the retry;
    closing the form deletes them DB-->>W: Realtime: expenses change W->>W: invalidate the expense (its attachments key sits under it) ``` -On the expense page each photo opens full screen through a signed URL (one hour). Members of other groups get no rows from `expense_attachments` and no object from the bucket. +Daily, `receipts-sweep` (pg_cron, then the edge function with the shared secret) deletes files older than a day that no expense or attachment names, through the Storage API. + +On the expense page each photo opens full screen through a signed URL (one hour), with pinch, double tap and button zoom and a swipe to the next one. Members of other groups get no rows from `expense_attachments` and no object from the bucket. ## Settle up From 2774fa76f1404e93b82d664b571d39176513724c Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:46:48 -0500 Subject: [PATCH 14/29] create an expense with its files in one call; every later change is logged 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> --- .../form/expense-form-receipts.test.tsx | 70 +++++++++++-------- .../features/expenses/form/expense-form.tsx | 25 ++++--- .../0021-receipt-scanning-and-attachments.md | 10 +-- docs/receipts.md | 5 +- docs/sequences.md | 16 +++-- packages/api/src/attachments.test.ts | 23 ++++++ packages/api/src/attachments.ts | 21 +++++- .../20261113173404_expense_attachments.sql | 61 ++++++++++------ supabase/tests/01_schema.test.sql | 2 + .../tests/29_expense_attachments.test.sql | 53 ++++++++++++-- 10 files changed, 207 insertions(+), 79 deletions(-) diff --git a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx index 10d8070..ece8d8a 100644 --- a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx @@ -2,6 +2,7 @@ import type * as Api from '@potluck/api' import { attachmentKeys, createExpense, + createExpenseWithAttachments, removeReceiptFiles, setExpenseAttachments, updateExpense, @@ -21,6 +22,7 @@ vi.mock('@potluck/api', async (importOriginal) => { return { ...(await importOriginal()), createExpense: vi.fn(async () => ({ id: 'new-expense' })), + createExpenseWithAttachments: vi.fn(async () => ({ id: 'new-expense' })), updateExpense: vi.fn(async (_c: unknown, id: string) => ({ id })), uploadReceipt: vi.fn(async (_c: unknown, groupId: string) => `${groupId}/up-${(n += 1)}.jpg`), setExpenseAttachments: vi.fn(async () => []), @@ -53,7 +55,7 @@ beforeEach(() => { }) describe('expense form files', () => { - it('uploads every file, sends the first as the receipt and then sets the list', async () => { + it('uploads every file and creates the expense with them in one call', async () => { const onDone = vi.fn() renderWithClient( {}} />, @@ -67,15 +69,13 @@ describe('expense form files', () => { await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) - expect(vi.mocked(createExpense).mock.calls[0]![1].receipt_path).toBe(`${GROUP_ID}/up-1.jpg`) - expect(vi.mocked(setExpenseAttachments)).toHaveBeenCalledWith( - expect.anything(), - 'new-expense', - [`${GROUP_ID}/up-1.jpg`, `${GROUP_ID}/up-2.jpg`], - ) + const [, , paths] = vi.mocked(createExpenseWithAttachments).mock.calls[0]! + expect(paths).toEqual([`${GROUP_ID}/up-1.jpg`, `${GROUP_ID}/up-2.jpg`]) + expect(vi.mocked(createExpense)).not.toHaveBeenCalled() + expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() }) - it('saves one file with the expense alone, and none without a call', async () => { + it('creates an expense without files the plain way', async () => { const onDone = vi.fn() renderWithClient( {}} />, @@ -84,11 +84,11 @@ describe('expense form files', () => { fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { target: { value: 'Lunch' }, }) - await addFiles([photo()]) fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) await waitFor(() => expect(onDone).toHaveBeenCalled()) - expect(vi.mocked(createExpense).mock.calls[0]![1].receipt_path).toMatch(/up-\d+\.jpg$/) - expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() + expect(vi.mocked(createExpense).mock.calls[0]![1].receipt_path).toBeNull() + expect(vi.mocked(createExpenseWithAttachments)).not.toHaveBeenCalled() + expect(vi.mocked(uploadReceipt)).not.toHaveBeenCalled() }) it('removes a stored file on edit without resending the expense receipt', async () => { @@ -140,7 +140,9 @@ describe('expense form files', () => { }) it('reuses uploads when a failed save is retried, and deletes them if the form closes', async () => { - vi.mocked(createExpense).mockRejectedValueOnce({ message: 'fx_rate_unavailable' }) + vi.mocked(createExpenseWithAttachments).mockRejectedValueOnce({ + message: 'fx_rate_unavailable', + }) const onDone = vi.fn() const view = renderWithClient( {}} />, @@ -151,23 +153,25 @@ describe('expense form files', () => { }) await addFiles([photo('a.jpg'), photo('b.jpg')]) fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) - await waitFor(() => expect(vi.mocked(createExpense)).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(vi.mocked(createExpenseWithAttachments)).toHaveBeenCalledTimes(1)) expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) expect(vi.mocked(uploadReceipt)).toHaveBeenCalledTimes(2) - const [first, second] = vi.mocked(createExpense).mock.calls - expect(second![1].receipt_path).toBe(first![1].receipt_path) + const [first, second] = vi.mocked(createExpenseWithAttachments).mock.calls + expect(second![2]).toEqual(first![2]) view.unmount() expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith( expect.anything(), - expect.arrayContaining([first![1].receipt_path]), + expect.arrayContaining([first![2][0]]), ) }) it('deletes uploads for a save that never went through when the form closes', async () => { - vi.mocked(createExpense).mockRejectedValueOnce({ message: 'fx_rate_unavailable' }) + vi.mocked(createExpenseWithAttachments).mockRejectedValueOnce({ + message: 'fx_rate_unavailable', + }) const view = renderWithClient( {}} onClose={() => {}} />, ) @@ -177,25 +181,29 @@ describe('expense form files', () => { }) await addFiles([photo('a.jpg')]) fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) - await waitFor(() => expect(vi.mocked(createExpense)).toHaveBeenCalledTimes(1)) - const path = vi.mocked(createExpense).mock.calls[0]![1].receipt_path + await waitFor(() => expect(vi.mocked(createExpenseWithAttachments)).toHaveBeenCalledTimes(1)) + const paths = vi.mocked(createExpenseWithAttachments).mock.calls[0]![2] view.unmount() - expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), [path]) + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), paths) }) - it('tells the person when the expense saved but its files did not', async () => { + it('tells the person when an edit saved but its files did not, and drops the new upload', async () => { vi.mocked(setExpenseAttachments).mockRejectedValueOnce({ message: 'attachment_not_found' }) const onDone = vi.fn() renderWithClient( - {}} />, + {}} + />, + seededClient((c) => c.setQueryData(attachmentKeys.list('e3'), [])), ) - fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) - fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { - target: { value: 'Lunch' }, - }) - await addFiles([photo('a.jpg'), photo('b.jpg')]) - fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) - await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + await addFiles([photo('a.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('e3')) + const [path] = vi.mocked(uploadReceipt).mock.results.map((r) => r.value as Promise) + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), [await path]) }) it('fills the form from a scanned receipt, split by item, and saves only when asked', async () => { @@ -211,7 +219,7 @@ describe('expense form files', () => { fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) - expect(vi.mocked(createExpense)).not.toHaveBeenCalled() + expect(vi.mocked(createExpenseWithAttachments)).not.toHaveBeenCalled() expect(screen.getByRole('textbox', { name: 'What was it?' })).toHaveValue('Taqueria Sol') expect(screen.getByDisplayValue('2026-11-08')).toBeInTheDocument() expect( @@ -225,7 +233,7 @@ describe('expense form files', () => { fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) await waitFor(() => expect(onDone).toHaveBeenCalled()) - const payload = vi.mocked(createExpense).mock.calls[0]![1] + const payload = vi.mocked(createExpenseWithAttachments).mock.calls[0]![1] expect(payload).toMatchObject({ description: 'Taqueria Sol', amount_cents: 1600, diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index 4b7e656..1663eef 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -3,6 +3,7 @@ import { attachmentQueries, categoryHistoryQueryOptions, createExpense, + createExpenseWithAttachments, MAX_ATTACHMENTS, moneyKeys, moneyQueries, @@ -398,11 +399,11 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { throw new Error('receipt_upload_failed') } } - // The first file also rides along as receipt_path on a new expense; the rest are set once - // it exists (ADR 21). Uploads the expense does not end up with are deleted. + // A new expense takes its files in the same call; an edit sets them once the expense is + // saved (ADR 21). Uploads the expense does not end up with are deleted. const withFiles = async (saved: Awaited>) => { - let kept = expense ? stored.map((a) => a.path) : paths.slice(0, 1) - if (filesChanged && (expense || paths.length > 1)) { + let kept = expense ? stored.map((a) => a.path) : paths + if (filesChanged && expense) { try { await saveAttachments(supabase, saved.id, paths, stored) kept = paths @@ -443,14 +444,16 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { const { splits: _splits, split_type: _type, ...details } = payload return withFiles(await updateExpense(supabase, expense.id, details)) } + const created: ExpensePayload = { + ...payload, + group_id: group.id, + ...(props.mode === 'create' && props.eventId ? { event_id: props.eventId } : {}), + ...(recurrence !== 'none' && !refund ? { recurrence } : {}), + } return withFiles( - await createExpense(supabase, { - ...payload, - receipt_path: paths[0] ?? null, - group_id: group.id, - ...(props.mode === 'create' && props.eventId ? { event_id: props.eventId } : {}), - ...(recurrence !== 'none' && !refund ? { recurrence } : {}), - }), + paths.length > 0 + ? await createExpenseWithAttachments(supabase, created, paths) + : await createExpense(supabase, { ...created, receipt_path: null }), ) }, onSuccess: async (saved) => { diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index 308b507..4097703 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -9,11 +9,11 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision -- **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy and keeps the better reading. +- **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. - **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. - **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before reading and uploading, which also drops their metadata. -- **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. Files sent right after the caller created the expense are part of creating it, so its history keeps one entry. -- **`receipt_path` stays the first attachment.** The new client sends the first file as `receipt_path` on create and sets the rest with `set_expense_attachments`; the function keeps `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. +- **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. +- **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. - **No orphaned files.** Uploads for a save that fails are reused when it is retried and deleted when the form closes without saving. A daily job (`receipts-sweep`) deletes what is left: files older than a day that no expense or attachment names, listed by `orphan_receipts` and removed by an edge function through the Storage API, so the stored objects go too. @@ -22,7 +22,7 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, - No per-scan cost and no receipt leaves the device for reading. The first scan downloads about 7 MB (worker, one core build, model); a device without that cache and without a connection cannot scan and says so. - Accuracy is the open source engine's: clean printed till receipts read well, crumpled or handwritten ones do not, and non-Latin scripts are not read (English model only). The review step and the boxes keep a bad read from becoming a bad expense. - `tesseract.js` and its core are in `package.json` with exact versions. Upgrading changes the `/ocr//` path, so an old cache is never mixed with a new worker. -- Two writes on save (the expense, then its files) are not atomic. If the second fails, the expense is saved with its first file and the person is told to add the rest by editing. +- Editing is two writes (the expense, then its files). If the second fails, the expense keeps its old files and the person is told to add them again; creating is one call. - Files removed by someone who did not upload them, and is not an admin, stay in storage (the bucket's delete rule) until the next sweep, unreferenced but readable by the group. -- A second reading costs another second or two, only when the first one does not add up. Measured on 18 rendered receipts (six layouts, clean, mild and phone photo): items matching the total went from 5 of 18 (JPEG copy, old parser) to 15 of 18; the misses are blurred, tilted photos. +- A second reading costs another second or two, only when the first one does not add up. Measured on 18 rendered receipts (six layouts, clean, mild and phone photo): items matching the total went from 5 of 18 (JPEG copy, old parser) to 16 of 18 (three readings), dates 18 of 18; the misses are a blurred, tilted photo and a long receipt whose prices the engine misreads. - The parser is heuristics. New receipt formats are new test cases in `parse-receipt.test.ts`. diff --git a/docs/receipts.md b/docs/receipts.md index 3c647c1..5051b35 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -20,7 +20,7 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The ## Rules - Only `ocr.ts` imports `tesseract.js`, and only with a dynamic `import()`, so it never lands in the app bundle or the precache. Its worker, core and model come from `/ocr//` on our origin. -- OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, a second pass reads an upscaled copy and the better reading wins. +- OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, a second pass reads an upscaled copy; when the best so far is still far off, a third reads the plain colour photo (which suits some long receipts better). The best reading wins (`bestScan`). - The upload copy (`prepareImage`) is at most 2000 px, JPEG on white. Re-encoding drops the photo's metadata, location included. HEIC outside Safari and PDFs go up as they are. - A scan only fills the form. The review sheet shows the photo and lets each price be corrected; the person saves the expense. - The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off). @@ -29,7 +29,8 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The ## Attachments in the database - `expense_attachments` holds up to five files per expense, in order. Files live in the private `receipts` bucket under `/`, so the bucket's member, uploader and two-step policies are the only storage rules. -- `set_expense_attachments(id, attachments)`: a list of storage paths (or `{path}` objects). Group row share lock, member, `require_group_open`, own expense or admin, not on a deleted expense. Each path starts with `/`, appears once and exists in the bucket (`attachment_not_found`); more than five is `too_many_attachments`. An unchanged list writes and logs nothing. Otherwise it rewrites the rows, sets `receipt_path` to the first file and logs one `expense.updated` with `changes.attachments = {from, to}`, except right after the caller created the expense (only its `expense.created` row so far, under ten minutes old): the files are part of creating it, so history keeps one entry. +- `set_expense_attachments(id, attachments)`: a list of storage paths (or `{path}` objects). Group row share lock, member, `require_group_open`, own expense or admin, not on a deleted expense. Each path starts with `/`, appears once and exists in the bucket (`attachment_not_found`); more than five is `too_many_attachments`. An unchanged list writes and logs nothing. Otherwise it rewrites the rows, sets `receipt_path` to the first file and logs one `expense.updated` with `changes.attachments = {from, to}`. Every change through it is logged, however soon after the create. +- `create_expense_with_attachments(payload, attachments)`: `create_expense_v2` and the same file checks in one transaction. The files are part of creating the expense, so its history has the one `expense.created` entry, and a refused file means nothing is created. The app creates every expense with files this way. - `receipt_path` is always the first attachment. A trigger mirrors an older client's writes: its file is replaced or dropped and the others stay. - Dropped files are deleted by the client when the bucket lets it (uploader or admin). Files uploaded for a save that never happened are deleted when the form closes, and kept for a retry while it is open. - The daily `receipts-sweep` job finds files in the bucket older than a day that no expense or attachment names (`private.orphan_receipts`) and has the `receipts-sweep` edge function delete them through the Storage API, which also removes the stored object. Deleting rows from `storage.objects` in SQL would leave the object behind. diff --git a/docs/sequences.md b/docs/sequences.md index e1bb265..7426fa0 100644 --- a/docs/sequences.md +++ b/docs/sequences.md @@ -359,7 +359,11 @@ sequenceDiagram O-->>W: text from a lossless grayscale copy (ocrImage pass 1) W->>C: parseReceipt(text): itemsMatch? opt items do not come to the total - W->>O: read again from an enlarged copy (pass 2), keep the better reading + W->>O: read again from an enlarged copy (pass 2) + opt still far off + W->>O: read the plain colour photo (pass 3) + end + W->>C: bestScan: the reading that comes closest end O-->>W: worker terminated C-->>W: merchant, date, currency, total, items, tax, tip, discount, itemsMatch @@ -370,9 +374,13 @@ sequenceDiagram par each new file W->>S: upload receipts/:groupId/:file (policy: member, two-step done) end - W->>DB: rpc create_expense_v2(payload, receipt_path = first file) - DB->>DB: trigger: expense_attachments row 0 = receipt_path - opt more than one file (or an edit changed them) + alt a new expense with files + W->>DB: rpc create_expense_with_attachments(payload, paths in order) + DB->>DB: save_expense, then the same file checks, in one transaction:
    one expense.created entry, nothing created if a file is refused + else a new expense without files + W->>DB: rpc create_expense_v2(payload) + end + opt an edit changed the files W->>DB: rpc set_expense_attachments(id, paths in order) DB->>DB: group share lock, member, own expense or admin,
    paths in the group folder and uploaded, five at most DB->>DB: rewrite expense_attachments, receipt_path = first,
    activity expense.updated {attachments: {from, to}} diff --git a/packages/api/src/attachments.test.ts b/packages/api/src/attachments.test.ts index 40a1dd2..2000bf2 100644 --- a/packages/api/src/attachments.test.ts +++ b/packages/api/src/attachments.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + createExpenseWithAttachments, attachmentKeys, attachmentQueries, fetchExpenseAttachments, @@ -46,6 +47,28 @@ describe('expense attachments', () => { }) }) + it('creates an expense with its files in one call', async () => { + const fake = fakeSupabase().respond('rpc:create_expense_with_attachments', { + data: { id: 'e1' }, + }) + const payload = { + group_id: 'g1', + description: 'Lunch', + amount_cents: 2000, + currency: 'USD', + paid_by: 'u1', + split_type: 'equal' as const, + splits: [{ user_id: 'u1' }], + spent_at: '2026-11-13', + } + expect(await createExpenseWithAttachments(fake.client, payload, ['g1/a.jpg'])).toEqual({ + id: 'e1', + }) + expect(fake.rpcArgs('create_expense_with_attachments')).toEqual([ + { payload, attachments: ['g1/a.jpg'] }, + ]) + }) + it('removes files quietly, and skips the call when there is nothing to remove', async () => { const fake = fakeSupabase() await removeReceiptFiles(fake.client, []) diff --git a/packages/api/src/attachments.ts b/packages/api/src/attachments.ts index 1ce0a70..06f8024 100644 --- a/packages/api/src/attachments.ts +++ b/packages/api/src/attachments.ts @@ -3,9 +3,9 @@ * expense (ADR 21). Platform-neutral: no DOM imports. */ import { queryOptions } from '@tanstack/react-query' -import type { Database } from './database.types' +import type { Database, Json } from './database.types' import type { PotluckClient } from './index' -import { moneyKeys } from './money' +import { moneyKeys, type ExpensePayload, type ExpenseRow } from './money' /** One file on an expense, in order; the first is also `expenses.receipt_path`. */ export type ExpenseAttachment = Pick< @@ -51,6 +51,23 @@ export async function setExpenseAttachments( return unwrap(await client.rpc('set_expense_attachments', { id: expenseId, attachments: paths })) } +/** + * Creates an expense with its files in one call (`paths` already uploaded, in order), so its + * history has one entry and a refused file means nothing is created. + */ +export async function createExpenseWithAttachments( + client: PotluckClient, + payload: ExpensePayload, + paths: string[], +): Promise { + return unwrap( + await client.rpc('create_expense_with_attachments', { + payload: payload as unknown as Json, + attachments: paths, + }), + ) +} + /** * Deletes files that are no longer on any expense. The bucket lets the uploader or a group admin * delete; anything else stays put, so this never throws. diff --git a/supabase/migrations/20261113173404_expense_attachments.sql b/supabase/migrations/20261113173404_expense_attachments.sql index 39b0a10..0283bc1 100644 --- a/supabase/migrations/20261113173404_expense_attachments.sql +++ b/supabase/migrations/20261113173404_expense_attachments.sql @@ -6,7 +6,7 @@ -- Members of the group read the rows; only set_expense_attachments and the trigger below write. -- -- expenses.receipt_path stays the first attachment, so older clients keep showing one receipt: --- * set_expense_attachments writes it along with the list; +-- * set_expense_attachments and create_expense_with_attachments write it along with the list; -- * an older client that sets or clears receipt_path through create_expense_v2 or -- update_expense_v2 changes the list the same way: its old file leaves, its new file goes -- first, the others stay. @@ -181,21 +181,21 @@ from public.expenses e where e.receipt_path is not null and e.receipt_path like e.group_id::text || '/%'; --- The RPC ---------------------------------------------------------------------------------------- +-- The RPCs --------------------------------------------------------------------------------------- -- Sets an expense's attachments, in order: `attachments` is a list of storage paths (or -- {path} objects) of files already uploaded under the group's folder. Up to five. Who may edit -- the expense may change them (a member their own expenses, admins any); they are details, so --- an expense that names someone who left still takes them. Logs one `expense.updated` with an --- `attachments` change (count before and after). Removing a file from the list does not delete --- it from storage; the client does that with the bucket's own rules. -create or replace function public.set_expense_attachments(id uuid, attachments jsonb) -returns setof public.expense_attachments +-- an expense that names someone who left still takes them. A changed list is logged as one +-- `expense.updated` with an `attachments` change (count before and after) when `log_edit`; the +-- create below passes false, since the files are part of creating the expense. Removing a file +-- from the list does not delete it from storage; the client does that with the bucket's rules. +create function private.apply_attachments(eid uuid, attachments jsonb, log_edit boolean) +returns void language plpgsql security definer set search_path = '' as $$ #variable_conflict use_column declare uid uuid := private.require_user(); - eid uuid := set_expense_attachments.id; my_role public.group_role; gid uuid; prev public.expenses; @@ -261,17 +261,7 @@ begin -- the same, so realtime tells everyone looking at the expense. This function logs the edit. perform set_config('potluck.expense_logged', eid::text, true); update public.expenses e set receipt_path = new_list -> 0 ->> 'path' where e.id = eid; - -- Files sent right after the caller created the expense are part of creating it: its - -- history keeps the one "added it" entry. - if exists ( - select 1 from public.activity a - where a.subject_id = eid and a.verb = 'expense.created' and a.actor_id = uid - and a.created_at > now() - interval '10 minutes' - ) and not exists ( - select 1 from public.activity a where a.subject_id = eid and a.verb <> 'expense.created' - ) then - return query - select a.* from public.expense_attachments a where a.expense_id = eid order by a.position; + if not log_edit then return; end if; perform private.log_activity(gid, uid, 'expense.updated', eid, jsonb_build_object( @@ -286,15 +276,46 @@ begin )) )); end if; +end; +$$; +revoke execute on function private.apply_attachments(uuid, jsonb, boolean) from public; + +create or replace function public.set_expense_attachments(id uuid, attachments jsonb) +returns setof public.expense_attachments +language plpgsql security definer set search_path = '' as $$ +begin + perform private.apply_attachments(set_expense_attachments.id, attachments, true); return query - select a.* from public.expense_attachments a where a.expense_id = eid order by a.position; + select a.* from public.expense_attachments a + where a.expense_id = set_expense_attachments.id + order by a.position; end; $$; revoke execute on function public.set_expense_attachments(uuid, jsonb) from public, anon; grant execute on function public.set_expense_attachments(uuid, jsonb) to authenticated, service_role; +-- create_expense_v2 with its files, in one transaction: `payload` as for create_expense_v2 (its +-- `receipt_path`, if any, is replaced by the first file), `attachments` as for +-- set_expense_attachments. The files are part of creating the expense, so its history has the +-- one `expense.created` entry. If any file is refused, nothing is created. +create or replace function public.create_expense_with_attachments(payload jsonb, attachments jsonb) +returns public.expenses +language plpgsql security definer set search_path = '' as $$ +declare + created public.expenses; +begin + select * into created from private.save_expense(null, payload); + perform private.apply_attachments(created.id, attachments, false); + select * into created from public.expenses e where e.id = created.id; + return created; +end; +$$; + +revoke execute on function public.create_expense_with_attachments(jsonb, jsonb) from public, anon; +grant execute on function public.create_expense_with_attachments(jsonb, jsonb) to authenticated, service_role; + -- Orphan sweep ------------------------------------------------------------------------------------- -- Service role only (the receipts-sweep edge function): files in the receipts bucket older than a diff --git a/supabase/tests/01_schema.test.sql b/supabase/tests/01_schema.test.sql index c7eeabc..277b3c0 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -13,6 +13,7 @@ begin; select plan(126); select plan(98); select plan(100); +select plan(101); select tables_are('public', array[ 'profiles', 'friendships', 'blocks', 'reports', @@ -80,6 +81,7 @@ select has_function('public', 'import_stage', array['uuid', 'uuid', 'jsonb'], 'i select has_function('public', 'export_my_data', array[]::text[], 'export_my_data'); select has_function('public', 'set_expense_attachments', array['uuid', 'jsonb'], 'set_expense_attachments'); select has_function('public', 'orphan_receipts', array['integer'], 'orphan_receipts'); +select has_function('public', 'create_expense_with_attachments', array['jsonb', 'jsonb'], 'create_expense_with_attachments'); select has_function('public', 'settle_up', array['uuid', 'uuid', 'bigint', 'text', 'text'], 'settle_up'); select has_function('public', 'delete_settlement', array['uuid'], 'delete_settlement'); select has_function('public', 'simplified_debts', array['uuid'], 'simplified_debts'); diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql index aa09d67..b7fab1b 100644 --- a/supabase/tests/29_expense_attachments.test.sql +++ b/supabase/tests/29_expense_attachments.test.sql @@ -3,7 +3,7 @@ -- two-step sign in read none of them; set_expense_attachments and the receipt_path mirror for -- older clients. begin; -select plan(51); +select plan(58); select tests.create_user('ava'); select tests.create_user('ben'); @@ -83,8 +83,14 @@ select is( select is( (select count(*)::int from public.activity where subject_id = tests.id('e') and verb = 'expense.updated'), - 0, - 'files sent right after creating the expense are part of creating it: one history entry' + 1, + 'changing the files right after creating the expense is an edit, and is logged' +); +select is( + (select data -> 'changes' -> 'attachments' from public.activity + where subject_id = tests.id('e') and verb = 'expense.updated'), + '{"from": 1, "to": 3}'::jsonb, + 'the edit says how many there were and are' ); select tests.login('ava'); @@ -96,7 +102,7 @@ select lives_ok( reset role; select is( (select count(*)::int from public.activity where subject_id = tests.id('e') and verb = 'expense.updated'), - 0, + 1, 'the same list changes nothing and logs nothing' ); @@ -335,5 +341,44 @@ reset role; select tests.login('ava'); select throws_ok($$ select public.orphan_receipts() $$, '42501', null, 'members cannot list orphans'); +-- Creating an expense with its files: one call, one history entry. +select tests.login('ava'); +select tests.remember('e2', (select id from public.create_expense_with_attachments( + jsonb_build_object('group_id', tests.id('g'), 'description', 'Lunch', 'amount_cents', 2000, + 'paid_by', tests.uid('ava'), 'splits', tests.people('ava', 'ben')), + pg_temp.files('five.jpg', 'six.jpg')))); +reset role; +select is(pg_temp.paths('e2'), 'five.jpg,six.jpg', 'a new expense takes its files in the same call'); +select is( + (select receipt_path from public.expenses where id = tests.id('e2')), + pg_temp.file('five.jpg'), + 'and the first one is its receipt' +); +select is( + (select array_agg(verb) from public.activity where subject_id = tests.id('e2')), + array['expense.created'], + 'and its history has exactly one entry' +); +select tests.login('ava'); +select throws_ok( + format($$ select public.create_expense_with_attachments(%L, %L) $$, + jsonb_build_object('group_id', tests.id('g'), 'description', 'Never saved', 'amount_cents', 900, + 'paid_by', tests.uid('ava'), 'splits', tests.people('ava')), + pg_temp.files('never-uploaded.jpg')), + 'PT404', 'attachment_not_found', 'a refused file stops the create' +); +reset role; +select is( + (select count(*)::int from public.expenses where description = 'Never saved'), + 0, + 'and nothing is created' +); +select tests.login_anon(); +select throws_ok( + $$ select public.create_expense_with_attachments('{}', '[]') $$, + '42501', null, 'anon cannot call it' +); +reset role; + select * from finish(); rollback; From 269c895348ae3835253aa7051dff7111c8b8c7e5 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:46:50 -0500 Subject: [PATCH 15/29] read a far-off receipt a third time in colour, and clean up its lines 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> --- .../features/expenses/receipts/ocr.test.ts | 22 ++++++++++++--- .../web/src/features/expenses/receipts/ocr.ts | 27 +++++++++++-------- .../expenses/receipts/scan-receipt.tsx | 10 +++++-- .../core/src/receipts/parse-receipt.test.ts | 17 ++++++++++++ packages/core/src/receipts/parse-receipt.ts | 13 +++++++-- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/ocr.test.ts b/apps/web/src/features/expenses/receipts/ocr.test.ts index d21fcd8..3448704 100644 --- a/apps/web/src/features/expenses/receipts/ocr.test.ts +++ b/apps/web/src/features/expenses/receipts/ocr.test.ts @@ -32,12 +32,14 @@ vi.mock('tesseract.js', () => ({ ), })) +const copies = vi.hoisted(() => ({ first: 'native' as unknown })) vi.mock('./image', () => ({ - ocrImage: vi.fn(async (_file: Blob, pass: 1 | 2) => (pass === 1 ? 'native' : 'closer')), + ocrImage: vi.fn(async (_file: Blob, pass: 1 | 2) => (pass === 1 ? copies.first : 'closer')), })) beforeEach(() => { - tess.texts = ['first', 'second'] + tess.texts = ['first', 'second', 'third'] + copies.first = 'native' tess.fail = false tess.seen = [] tess.terminate.mockClear() @@ -58,7 +60,7 @@ describe('readReceiptText', () => { it('reads again closer up when the first reading falls short', async () => { const stages: string[] = [] const texts = await readReceiptText(photo, { - enough: (text) => text === 'second', + enough: (texts) => texts.includes('second'), onProgress: (p) => stages.push(p.stage), }) expect(texts).toEqual(['first', 'second']) @@ -66,6 +68,20 @@ describe('readReceiptText', () => { expect(stages).toContain('closer') }) + it('reads the plain colour photo last when the others still fall short', async () => { + const texts = await readReceiptText(photo, { enough: () => false }) + expect(texts).toEqual(['first', 'second', 'third']) + expect(tess.seen).toEqual(['native', 'closer', photo]) + }) + + it('never reads the same photo twice', async () => { + // The photo could not be decoded, so the first reading already used the original. + copies.first = photo + const texts = await readReceiptText(photo, { enough: () => false }) + expect(tess.seen).toEqual([photo, 'closer']) + expect(texts).toEqual(['first', 'second']) + }) + it('says the reader is unavailable when it cannot start', async () => { tess.fail = true await expect(readReceiptText(photo)).rejects.toBeInstanceOf(ReaderUnavailableError) diff --git a/apps/web/src/features/expenses/receipts/ocr.ts b/apps/web/src/features/expenses/receipts/ocr.ts index cf74fe7..d4a88ce 100644 --- a/apps/web/src/features/expenses/receipts/ocr.ts +++ b/apps/web/src/features/expenses/receipts/ocr.ts @@ -23,9 +23,10 @@ export class ReaderUnavailableError extends Error { * and English model come from our own origin (`ocr-assets.ts`) and the service worker keeps them, * so later scans work offline. * - * The reader sees a lossless grayscale copy of the original photo (`ocrImage`). When `enough` - * says the first reading falls short, it reads once more from an enlarged copy; the texts come - * back in order, for the caller to pick from. The worker is ended afterwards, so the memory it + * The reader sees a lossless grayscale copy of the original photo (`ocrImage`). While `enough` + * says the readings so far fall short, it reads again: from an enlarged copy, then from the + * plain colour photo, which suits some long receipts better. The texts come back in order, for + * the caller to pick from. The worker is ended afterwards, so the memory it * takes (around 100 MB) goes back at once. Aborting `signal` stops it. */ export async function readReceiptText( @@ -37,12 +38,12 @@ export async function readReceiptText( }: { onProgress?: (p: ReadProgress) => void signal?: AbortSignal - enough?: (text: string) => boolean + enough?: (texts: string[]) => boolean } = {}, ): Promise { signal?.throwIfAborted() const base = new URL(`${__OCR_PATH__}/`, window.location.origin).href - let pass: 1 | 2 = 1 + let pass = 1 let worker: Worker try { const { createWorker, OEM } = await import('tesseract.js') @@ -68,14 +69,18 @@ export async function readReceiptText( signal?.addEventListener('abort', stop, { once: true }) }) const texts: string[] = [] + const read = new Set() + const images = [() => ocrImage(original, 1), () => ocrImage(original, 2), async () => original] try { - for (const next of [1, 2] as const) { - pass = next - const image = await Promise.race([ocrImage(original, next), stopped]) - if (!image) break - const { data } = await Promise.race([worker.recognize(image), stopped]) + for (const [index, image] of images.entries()) { + pass = index + 1 + const blob = await Promise.race([image(), stopped]) + // A copy that could not be made, or the same photo again, adds nothing. + if (!blob || read.has(blob)) continue + read.add(blob) + const { data } = await Promise.race([worker.recognize(blob), stopped]) texts.push(data.text) - if (enough(data.text)) break + if (enough(texts)) break } return texts } finally { diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index 32ecc6d..5dc7d20 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -102,7 +102,12 @@ export function ScanReceipt({ signal: controller.signal, onProgress: ({ stage, progress }) => setState((s) => (s.phase === 'reading' ? { ...s, stage, progress } : s)), - enough: (text) => parse(text).itemsMatch === 'exact', + // One reading that adds up is enough; after two, anything close is. Only a reading that + // is still far off tries the plain colour photo too. + enough: (texts) => { + const best = bestScan(texts.map(parse)).itemsMatch + return best === 'exact' || (texts.length > 1 && best === 'close') + }, }) const scan = bestScan((texts.length > 0 ? texts : ['']).map(parse)) const photo = await upload @@ -405,7 +410,8 @@ function Review({
    diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index aed840f..039833d 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -453,6 +453,23 @@ describe('lines that are not items', () => { }) }) +describe('scan noise', () => { + it('keeps rule lines and smudges off item names', () => { + const scan = parseReceipt( + 'Pub\n| Pint Lager £5.40\n> Crisps £1.60\n_ Scampi_ £13.00\nTOTAL £20.00', + { currency: 'GBP' }, + ) + expect(scan.items.map((i) => i.description)).toEqual(['Pint Lager', 'Crisps', 'Scampi']) + }) + + it('reads a blurred 0 in a day as the 0 it was', () => { + // The UK pub photo: "07/11/2026" came back from the reader as "97/11/2026". + expect(parseReceipt('97/11/2026 21:14', { currency: 'GBP' }).date).toBe('2026-11-07') + expect(parseReceipt('86/11/2026', { currency: 'GBP' }).date).toBe('2026-11-06') + expect(parseReceipt('45/11/2026', { currency: 'GBP' }).date).toBeNull() + }) +}) + describe('do the items match the total', () => { const match = (text: string) => parseReceipt(text, { currency: 'USD' }).itemsMatch diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index cfb3d27..6125ae2 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -266,7 +266,11 @@ function datesIn(key: string, order: 'dmy' | 'mdy'): string[] { const b = Number(m[3]) const year = fullYear(m[4]!) const dayFirst = m[2] === '.' || a > 12 || (order === 'dmy' && b <= 12) - push(dayFirst ? isoDay(year, b, a) : isoDay(year, a, b)) + const day = dayFirst ? isoDay(year, b, a) : isoDay(year, a, b) + // A blurred 0 often reads as 9, 8 or 6: "97/11/2026" was the 7th. Only a day no calendar + // has gets that second chance. + const repaired = !day && a > 31 && /^[689]\d$/.test(m[1]!) ? isoDay(year, b, a % 10) : null + push(day ?? repaired) } for (const m of key.matchAll( /\b(\d{1,2})(?:\.|st|nd|rd|th)?\s*(?:de\s+)?([a-z]{3,10})\.?,?\s*(?:de\s+)?(\d{4}|\d{2})\b/g, @@ -328,6 +332,8 @@ const QTY_LINE = /^[^\p{L}\d]*(\d{1,3})?\s*(?:stk|st|pcs?|pz|ea|un|uds?|und|units?)?\.?\s*[x×@*]\s*$/iu const UNIT_AT_END = new RegExp(String.raw`\s+@\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, 'u') +const JUNK_EDGES = /^[\s.,:;\-*#|>_~'"`«»[\]){}\\/]+|[\s.,:;\-*#|>_~'"`«»[\]{}\\/]+$/g + function itemOf(rest: string): { description: string; quantity: number } { let text = rest.replace(/^\d{4,}\s+/, '') let quantity = 1 @@ -348,7 +354,10 @@ function itemOf(rest: string): { description: string; quantity: number } { quantity = Number(first[1]) text = text.slice(first[0].length) } - const description = titleCase(text.replace(/^[\s.:\-*#]+|[\s.:\-*#]+$/g, '').replace(/\s+/g, ' ')) + // Rule lines, table borders and smudges read as "|", ">", "_" or quotes stay off the name. + const description = titleCase( + text.replace(JUNK_EDGES, '').replace(/\s+/g, ' ').replace(JUNK_EDGES, ''), + ) return { description: description.slice(0, ITEM_NAME_MAX).trim(), quantity: Math.max(quantity, 1), From bf086632cf18c40d6616143a92d8d82dd83d50a9 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:46:52 -0500 Subject: [PATCH 16/29] zoom buttons under the photo, keyboard zoom and panning, PDF names on 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> --- .../expenses/receipts/attachment-gallery.tsx | 14 +++-- .../expenses/receipts/attachments-field.tsx | 4 +- .../expenses/receipts/use-zoom-pan.test.tsx | 22 ++++++++ .../expenses/receipts/use-zoom-pan.ts | 29 +++++++++++ .../expenses/receipts/zoomable-image.tsx | 52 ++++++++++++------- packages/i18n/src/de/money.ts | 1 + packages/i18n/src/en/money.ts | 1 + packages/i18n/src/es/money.ts | 1 + packages/i18n/src/fr/money.ts | 1 + packages/i18n/src/pt/money.ts | 1 + supabase/functions/receipts-sweep/index.ts | 2 + 11 files changed, 105 insertions(+), 23 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx index eb25478..ae8c242 100644 --- a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -112,11 +112,16 @@ function Thumb({ target="_blank" rel="noreferrer" aria-label={t('expenses.attachments.open', { name })} - className="flex h-full min-h-24 items-center gap-3 rounded-card border border-line bg-card p-4 font-semibold hover:border-line-strong" + className={cn( + 'flex items-center gap-2 rounded-card border border-line bg-card font-semibold whitespace-nowrap hover:border-line-strong', + large ? 'min-h-24 p-4' : 'aspect-square flex-col justify-center p-2', + )} > - {name} - + + {name} + + ) : ( @@ -222,7 +227,8 @@ function Viewer({ src={url} alt={t('expenses.attachments.alt', { number: index + 1, description })} onSwipe={count > 1 ? onMove : undefined} - className="h-[calc(100dvh-11rem)] min-h-[50dvh] w-full rounded-card bg-sunken" + className="h-[calc(100dvh-11rem)] min-h-[50dvh] w-full" + frameClassName="rounded-card bg-sunken" /> ) : ( diff --git a/apps/web/src/features/expenses/receipts/attachments-field.tsx b/apps/web/src/features/expenses/receipts/attachments-field.tsx index 5cf11a2..abca386 100644 --- a/apps/web/src/features/expenses/receipts/attachments-field.tsx +++ b/apps/web/src/features/expenses/receipts/attachments-field.tsx @@ -164,7 +164,9 @@ function Tile({ className="flex size-20 flex-col items-center justify-center gap-1 rounded-[0.875rem] border border-line bg-sunken text-xs font-semibold text-mute" > - {name} + + {name} + )} { expect(img.style.transform).toContain('scale(1)') }) + it('zooms and moves with the keyboard, leaving arrows to the page when not zoomed', () => { + const onPage = vi.fn() + document.addEventListener('keydown', onPage) + render() + const { img, frame } = frameOf() + fireEvent.keyDown(frame, { key: 'ArrowLeft' }) + expect(onPage).toHaveBeenCalledTimes(1) + fireEvent.keyDown(frame, { key: '+' }) + expect(img.style.transform).toContain('scale(1.5)') + fireEvent.keyDown(frame, { key: 'ArrowLeft' }) + expect(img.style.transform).toContain('translate(48px, 0px)') + fireEvent.keyDown(frame, { key: 'ArrowDown' }) + expect(img.style.transform).toContain('translate(48px, -48px)') + expect(onPage).toHaveBeenCalledTimes(1) + fireEvent.keyDown(frame, { key: '-' }) + fireEvent.keyDown(frame, { key: '=' }) + fireEvent.keyDown(frame, { key: '0' }) + expect(img.style.transform).toContain('scale(1)') + fireEvent.keyDown(frame, { key: 'a' }) + document.removeEventListener('keydown', onPage) + }) + it('double clicks to zoom in and out', () => { render() const { img, frame } = frameOf() diff --git a/apps/web/src/features/expenses/receipts/use-zoom-pan.ts b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts index ef6f24b..d43f4c8 100644 --- a/apps/web/src/features/expenses/receipts/use-zoom-pan.ts +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts @@ -2,6 +2,7 @@ import { useRef, useState, type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from 'react' @@ -16,6 +17,8 @@ export const MAX_ZOOM = 4 const TAP_ZOOM = 2.5 const SWIPE = 60 const DOUBLE_TAP_MS = 300 +/** How far an arrow key moves a zoomed photo. */ +const KEY_STEP = 48 const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) @@ -146,6 +149,31 @@ export function useZoomPan({ onSwipe }: { onSwipe?: (step: 1 | -1) => void } = { const step = (factor: number) => settle(zoomAt(view, view.scale * factor, 0, 0)) + // Keyboard: + and - zoom, 0 resets, arrows move a zoomed photo. Unzoomed, arrows are left to + // the page (the viewer uses them to go to the next photo). + const onKeyDown = (e: ReactKeyboardEvent) => { + const moves: Record = { + ArrowLeft: [KEY_STEP, 0], + ArrowRight: [-KEY_STEP, 0], + ArrowUp: [0, KEY_STEP], + ArrowDown: [0, -KEY_STEP], + } + const move = moves[e.key] + if (move && view.scale > 1) { + settle({ ...view, x: view.x + move[0], y: view.y + move[1] }) + } else if (e.key === '+' || e.key === '=') { + step(1.5) + } else if (e.key === '-') { + step(1 / 1.5) + } else if (e.key === '0') { + setView({ scale: 1, x: 0, y: 0 }) + } else { + return + } + e.preventDefault() + e.stopPropagation() + } + const style: CSSProperties = { transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`, transition: dragging ? 'none' : 'transform 180ms ease-out', @@ -164,6 +192,7 @@ export function useZoomPan({ onSwipe }: { onSwipe?: (step: 1 | -1) => void } = { onPointerUp, onPointerCancel: onPointerUp, onDoubleClick, + onKeyDown, style: { touchAction: 'none' } as CSSProperties, }, zoomIn: () => step(1.5), diff --git a/apps/web/src/features/expenses/receipts/zoomable-image.tsx b/apps/web/src/features/expenses/receipts/zoomable-image.tsx index 10632e8..7e25a4a 100644 --- a/apps/web/src/features/expenses/receipts/zoomable-image.tsx +++ b/apps/web/src/features/expenses/receipts/zoomable-image.tsx @@ -1,47 +1,63 @@ import { t } from '@potluck/i18n' import { IconButton, cn } from '@potluck/ui' import { ZoomIn, ZoomOut } from 'lucide-react' +import { useId } from 'react' import { MAX_ZOOM, useZoomPan } from './use-zoom-pan' /** - * A photo that pinches, double taps and drags to zoom, with buttons for the keyboard. Swiping - * sideways when it is not zoomed calls `onSwipe`. Key it by `src` so a new photo starts unzoomed. + * A photo that pinches, double taps and drags to zoom. The zoom buttons sit under it, never on + * the print; with the photo focused, + and - zoom and the arrow keys move it. Swiping sideways + * when it is not zoomed calls `onSwipe`. Key it by `src` so a new photo starts unzoomed. */ export function ZoomableImage({ src, alt, onSwipe, className, - imageClassName, + frameClassName, }: { src: string alt: string onSwipe?: (step: 1 | -1) => void + /** The whole block: frame and buttons. */ className?: string - imageClassName?: string + /** The frame the photo sits in: its height, border and background. */ + frameClassName?: string }) { const zoom = useZoomPan({ onSwipe }) + const hint = useId() return ( -
    - {alt} - +
    +
    1 && 'cursor-grab', + frameClassName, + )} + > + {alt} +
    + + {t('expenses.attachments.zoomKeys')} + + } disabled={zoom.view.scale <= 1} - onPointerDown={(e) => e.stopPropagation()} onClick={zoom.zoomOut} + className="disabled:text-mute/60 disabled:opacity-100" /> } disabled={zoom.view.scale >= MAX_ZOOM} - onPointerDown={(e) => e.stopPropagation()} onClick={zoom.zoomIn} + className="disabled:text-mute/60 disabled:opacity-100" />
    diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index 6480945..a3089a1 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -429,6 +429,7 @@ export const expenses = { 'Gespeichert, aber die Dateien wurden nicht angehängt. Bearbeite die Ausgabe, um sie erneut hinzuzufügen.', zoomIn: 'Vergrößern', zoomOut: 'Verkleinern', + zoomKeys: 'Mit + und - zoomen, mit den Pfeiltasten verschieben, 0 setzt zurück.', }, scan: { button: 'Beleg scannen', diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index b0c2e47..c2c336d 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -429,6 +429,7 @@ export const expenses = { partial: 'Saved, but the files did not attach. Edit the expense to add them again.', zoomIn: 'Zoom in', zoomOut: 'Zoom out', + zoomKeys: 'Zoom with + and -, move with the arrow keys, 0 to start over.', }, /** Reading a receipt photo on the device to fill in the form. */ scan: { diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index 9d17764..1d6ce27 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -422,6 +422,7 @@ export const expenses = { 'Guardado, pero los archivos no se adjuntaron. Edita el gasto para añadirlos otra vez.', zoomIn: 'Acercar', zoomOut: 'Alejar', + zoomKeys: 'Acerca con + y -, muévete con las flechas, 0 para volver al inicio.', }, scan: { button: 'Escanear un recibo', diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index 252657b..f6e4bf1 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -429,6 +429,7 @@ export const expenses = { "C'est enregistré, mais les fichiers ne sont pas passés. Modifie la dépense pour les rajouter.", zoomIn: 'Agrandir', zoomOut: 'Réduire', + zoomKeys: 'Zoome avec + et -, déplace avec les flèches, 0 pour revenir au début.', }, scan: { button: 'Scanner un reçu', diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index ba130c4..aebc995 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -422,6 +422,7 @@ export const expenses = { 'Salvo, mas os arquivos não foram anexados. Edite a despesa para adicioná-los de novo.', zoomIn: 'Aproximar', zoomOut: 'Afastar', + zoomKeys: 'Aproxime com + e -, mova com as setas, 0 para voltar ao início.', }, scan: { button: 'Escanear um recibo', diff --git a/supabase/functions/receipts-sweep/index.ts b/supabase/functions/receipts-sweep/index.ts index 236ab36..a91c587 100644 --- a/supabase/functions/receipts-sweep/index.ts +++ b/supabase/functions/receipts-sweep/index.ts @@ -7,6 +7,8 @@ import { hasEdgeSecret, json } from '../_shared/http.ts' import { sweepOrphans } from './sweep.ts' Deno.serve(async (req) => { + // pg_net posts; nothing else should ever reach this. + if (req.method !== 'POST') return json({ error: 'method_not_allowed' }, 405) if (!hasEdgeSecret(req)) return json({ error: 'forbidden' }, 403) const admin = adminClient() try { From c53e349c0065fdbcac128d8ff95bdec97d9379a2 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:52:22 -0500 Subject: [PATCH 17/29] review photo keeps its height on phones; only the viewer frame stretches Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- apps/web/src/features/expenses/receipts/attachment-gallery.tsx | 2 +- apps/web/src/features/expenses/receipts/zoomable-image.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx index ae8c242..521eacd 100644 --- a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -228,7 +228,7 @@ function Viewer({ alt={t('expenses.attachments.alt', { number: index + 1, description })} onSwipe={count > 1 ? onMove : undefined} className="h-[calc(100dvh-11rem)] min-h-[50dvh] w-full" - frameClassName="rounded-card bg-sunken" + frameClassName="min-h-0 flex-1 rounded-card bg-sunken" /> ) : ( diff --git a/apps/web/src/features/expenses/receipts/zoomable-image.tsx b/apps/web/src/features/expenses/receipts/zoomable-image.tsx index 7e25a4a..ecb8a84 100644 --- a/apps/web/src/features/expenses/receipts/zoomable-image.tsx +++ b/apps/web/src/features/expenses/receipts/zoomable-image.tsx @@ -33,7 +33,7 @@ export function ZoomableImage({ tabIndex={0} aria-describedby={hint} className={cn( - 'relative grid min-h-0 flex-1 cursor-zoom-in place-items-center overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink', + 'relative grid cursor-zoom-in place-items-center overflow-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink', zoom.view.scale > 1 && 'cursor-grab', frameClassName, )} From 4add2261607a1b7f25bd7f8d651b730c4d40bf98 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:57:31 -0500 Subject: [PATCH 18/29] attachments after groups: migration renamed past main's newest, schema 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> --- docs/agent-map.json | 596 +++++++++++------- docs/architecture.md | 1 + docs/development.md | 1 + docs/receipts.md | 1 + packages/api/src/database.types.ts | 33 + ...=> 20261114015104_expense_attachments.sql} | 0 supabase/schema/current.sql | 420 ++++++++++++ supabase/tests/01_schema.test.sql | 7 +- 8 files changed, 828 insertions(+), 231 deletions(-) rename supabase/migrations/{20261113173404_expense_attachments.sql => 20261114015104_expense_attachments.sql} (100%) diff --git a/docs/agent-map.json b/docs/agent-map.json index 3a3e16a..82ea873 100644 --- a/docs/agent-map.json +++ b/docs/agent-map.json @@ -266,7 +266,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5150, + "snapshotLine": 5378, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -284,7 +284,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5205, + "snapshotLine": 5433, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -294,7 +294,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "not_a_friend"], - "snapshotLine": 5275, + "snapshotLine": 5503, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -304,7 +304,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["already_member", "placeholder_exists"], - "snapshotLine": 5302, + "snapshotLine": 5530, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -314,7 +314,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5380, + "snapshotLine": 5608, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -324,7 +324,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "person_not_found"], - "snapshotLine": 5449, + "snapshotLine": 5677, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -334,7 +334,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 5486, + "snapshotLine": 5714, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -344,7 +344,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_done"], - "snapshotLine": 5515, + "snapshotLine": 5743, "definedIn": "supabase/migrations/20261105071521_rpc_events_timeline.sql" }, { @@ -354,7 +354,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5542, + "snapshotLine": 5770, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -364,7 +364,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5565, + "snapshotLine": 5793, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -374,9 +374,19 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5579, + "snapshotLine": 5807, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, + { + "name": "public.create_expense_with_attachments", + "args": "payload jsonb, attachments jsonb", + "returns": "public.expenses", + "securityDefiner": true, + "execute": ["authenticated", "service_role"], + "raises": [], + "snapshotLine": 5818, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "public.create_friend_invite", "args": "email text", @@ -384,7 +394,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_email", "invalid_friend", "too_many_invites"], - "snapshotLine": 5614, + "snapshotLine": 5860, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -394,7 +404,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency"], - "snapshotLine": 5697, + "snapshotLine": 5943, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -404,7 +414,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency", "trip_dates_invalid"], - "snapshotLine": 5735, + "snapshotLine": 5981, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -419,7 +429,7 @@ "invalid_guest_limit", "invalid_guest_link_ttl" ], - "snapshotLine": 5803, + "snapshotLine": 6049, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -436,7 +446,7 @@ "invalid_role", "not_allowed" ], - "snapshotLine": 5878, + "snapshotLine": 6124, "definedIn": "supabase/migrations/20261105071508_rpc_invites.sql" }, { @@ -446,7 +456,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 5932, + "snapshotLine": 6178, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -456,7 +466,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 5969, + "snapshotLine": 6215, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -466,7 +476,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["direct_group_not_deletable", "group_has_balances", "not_allowed"], - "snapshotLine": 6007, + "snapshotLine": 6253, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -476,7 +486,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 6058, + "snapshotLine": 6304, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -486,7 +496,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 6162, + "snapshotLine": 6408, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -496,7 +506,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor"], - "snapshotLine": 6207, + "snapshotLine": 6453, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -506,7 +516,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6298, + "snapshotLine": 6544, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -516,7 +526,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invalid_payload", "invalid_unsubscribe_link"], - "snapshotLine": 6333, + "snapshotLine": 6579, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -526,7 +536,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6423, + "snapshotLine": 6669, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -536,7 +546,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6501, + "snapshotLine": 6747, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -546,7 +556,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_friend"], - "snapshotLine": 6512, + "snapshotLine": 6758, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -556,7 +566,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6617, + "snapshotLine": 6863, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -566,7 +576,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 6655, + "snapshotLine": 6901, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -576,7 +586,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "not_a_friend"], - "snapshotLine": 6704, + "snapshotLine": 6950, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -586,7 +596,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["guest_link_not_found"], - "snapshotLine": 6765, + "snapshotLine": 7011, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -596,7 +606,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import", "not_a_friend"], - "snapshotLine": 6875, + "snapshotLine": 7121, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -606,7 +616,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import"], - "snapshotLine": 7211, + "snapshotLine": 7457, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -616,7 +626,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7272, + "snapshotLine": 7518, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -626,7 +636,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7331, + "snapshotLine": 7577, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -636,7 +646,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "owner_must_transfer"], - "snapshotLine": 7423, + "snapshotLine": 7669, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -653,7 +663,7 @@ "not_allowed", "split_member_not_in_group" ], - "snapshotLine": 7478, + "snapshotLine": 7724, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -663,7 +673,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7595, + "snapshotLine": 7841, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -673,7 +683,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7609, + "snapshotLine": 7855, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -683,7 +693,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7620, + "snapshotLine": 7866, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -693,7 +703,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7641, + "snapshotLine": 7887, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -703,7 +713,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7659, + "snapshotLine": 7905, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -713,7 +723,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7692, + "snapshotLine": 7938, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -723,7 +733,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7718, + "snapshotLine": 7964, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -733,9 +743,19 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7773, + "snapshotLine": 8019, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, + { + "name": "public.orphan_receipts", + "args": "max_count integer DEFAULT 500", + "returns": "SETOF text", + "securityDefiner": true, + "execute": ["service_role"], + "raises": [], + "snapshotLine": 8032, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "public.record_mfa_recover_attempt", "args": "target uuid", @@ -743,7 +763,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7786, + "snapshotLine": 8049, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -759,7 +779,7 @@ "note_too_long", "nothing_owed" ], - "snapshotLine": 7833, + "snapshotLine": 8096, "definedIn": "supabase/migrations/20261113115303_debt_reminders.sql" }, { @@ -769,7 +789,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_found", "placeholder_already_reminded", "placeholder_no_email"], - "snapshotLine": 7897, + "snapshotLine": 8160, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -779,7 +799,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["friend_has_balance"], - "snapshotLine": 7940, + "snapshotLine": 8203, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -789,7 +809,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_not_found"], - "snapshotLine": 7967, + "snapshotLine": 8230, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -805,7 +825,7 @@ "not_allowed", "use_leave_group" ], - "snapshotLine": 7999, + "snapshotLine": 8262, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -815,7 +835,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "member_not_found", "not_allowed"], - "snapshotLine": 8046, + "snapshotLine": 8309, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -825,7 +845,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_polling", "member_not_found"], - "snapshotLine": 8084, + "snapshotLine": 8347, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -835,7 +855,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 8153, + "snapshotLine": 8416, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -845,7 +865,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_not_found", "not_a_member"], - "snapshotLine": 8192, + "snapshotLine": 8455, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -855,7 +875,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 8239, + "snapshotLine": 8502, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -865,7 +885,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_link_not_found", "not_allowed"], - "snapshotLine": 8284, + "snapshotLine": 8547, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -875,7 +895,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["whiteboard_conflict", "whiteboard_too_long"], - "snapshotLine": 8335, + "snapshotLine": 8598, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -885,7 +905,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_schedulable", "starts_at_required"], - "snapshotLine": 8396, + "snapshotLine": 8659, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -895,7 +915,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_date_range", "invalid_query"], - "snapshotLine": 8430, + "snapshotLine": 8693, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -905,7 +925,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["duplicate_slot", "event_not_polling", "invalid_slot", "invalid_slots"], - "snapshotLine": 8692, + "snapshotLine": 8955, "definedIn": "supabase/migrations/20261106020000_availability_lock.sql" }, { @@ -915,9 +935,19 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_splits", "split_member_not_in_group"], - "snapshotLine": 8781, + "snapshotLine": 9044, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, + { + "name": "public.set_expense_attachments", + "args": "id uuid, attachments jsonb", + "returns": "SETOF public.expense_attachments", + "securityDefiner": true, + "execute": ["authenticated", "service_role"], + "raises": [], + "snapshotLine": 9127, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "public.set_guest_availability", "args": "code text, name text, token text, slots jsonb", @@ -936,7 +966,7 @@ "guest_slots_required", "invalid_guest_name" ], - "snapshotLine": 8842, + "snapshotLine": 9144, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -951,7 +981,7 @@ "not_allowed", "use_transfer_ownership" ], - "snapshotLine": 8932, + "snapshotLine": 9234, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -961,7 +991,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_notification_category", "notification_setting_required"], - "snapshotLine": 8972, + "snapshotLine": 9274, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -971,7 +1001,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9009, + "snapshotLine": 9311, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -987,7 +1017,7 @@ "invalid_recipient", "member_not_found" ], - "snapshotLine": 9048, + "snapshotLine": 9350, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -997,7 +1027,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9106, + "snapshotLine": 9408, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1007,7 +1037,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_amount", "invalid_parties", "invalid_payload", "nothing_owed"], - "snapshotLine": 9143, + "snapshotLine": 9445, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1017,7 +1047,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9227, + "snapshotLine": 9529, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -1027,7 +1057,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_allowed", "recurring_not_found"], - "snapshotLine": 9287, + "snapshotLine": 9589, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1037,7 +1067,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_currency", "invalid_date_range"], - "snapshotLine": 9336, + "snapshotLine": 9638, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1047,7 +1077,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["already_owner", "member_not_found", "not_allowed"], - "snapshotLine": 9481, + "snapshotLine": 9783, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1057,7 +1087,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9519, + "snapshotLine": 9821, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1067,7 +1097,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9538, + "snapshotLine": 9840, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1083,7 +1113,7 @@ "placeholder_exists", "too_many_invites" ], - "snapshotLine": 9554, + "snapshotLine": 9856, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1108,7 +1138,7 @@ "split_member_not_in_group", "splits_sum_mismatch" ], - "snapshotLine": 9657, + "snapshotLine": 9959, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1124,7 +1154,7 @@ "settlement_deleted", "settlement_not_found" ], - "snapshotLine": 9914, + "snapshotLine": 10216, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" } ], @@ -1139,6 +1169,24 @@ "snapshotLine": 118, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, + { + "name": "private.apply_attachments", + "args": "eid uuid, attachments jsonb, log_edit boolean", + "returns": "void", + "securityDefiner": true, + "execute": [], + "raises": [ + "attachment_not_found", + "expense_deleted", + "expense_not_found", + "invalid_payload", + "invalid_receipt_path", + "not_allowed", + "too_many_attachments" + ], + "snapshotLine": 141, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "private.ascii_lower", "args": "value text", @@ -1146,9 +1194,29 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 141, + "snapshotLine": 235, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, + { + "name": "private.attachment_item", + "args": "file_path text, who uuid", + "returns": "jsonb", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 246, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, + { + "name": "private.attachment_list", + "args": "eid uuid", + "returns": "jsonb", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 269, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "private.befriend", "args": "a uuid, b uuid", @@ -1156,7 +1224,7 @@ "securityDefiner": true, "execute": [], "raises": ["blocked"], - "snapshotLine": 152, + "snapshotLine": 291, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1166,7 +1234,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 170, + "snapshotLine": 309, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1176,7 +1244,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 203, + "snapshotLine": 342, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1186,7 +1254,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 223, + "snapshotLine": 362, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1196,7 +1264,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 242, + "snapshotLine": 381, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1206,7 +1274,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["payers_sum_mismatch"], - "snapshotLine": 254, + "snapshotLine": 393, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1216,7 +1284,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["splits_sum_mismatch"], - "snapshotLine": 294, + "snapshotLine": 433, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1226,7 +1294,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["group_owner_required"], - "snapshotLine": 335, + "snapshotLine": 474, "definedIn": "supabase/migrations/20261105071420_groups_members_invites.sql" }, { @@ -1236,7 +1304,7 @@ "securityDefiner": true, "execute": [], "raises": ["invalid_amount", "invalid_parties", "member_not_in_group", "not_a_party"], - "snapshotLine": 374, + "snapshotLine": 513, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1246,7 +1314,7 @@ "securityDefiner": false, "execute": ["anon", "authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 406, + "snapshotLine": 545, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -1261,7 +1329,7 @@ "placeholder_claim_conflict", "placeholder_not_found" ], - "snapshotLine": 424, + "snapshotLine": 563, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1271,7 +1339,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 568, + "snapshotLine": 707, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1281,7 +1349,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 606, + "snapshotLine": 745, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1291,7 +1359,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 617, + "snapshotLine": 756, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1301,7 +1369,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 638, + "snapshotLine": 777, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1311,7 +1379,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["not_allowed"], - "snapshotLine": 658, + "snapshotLine": 797, "definedIn": "supabase/migrations/20261105071501_rls_platform.sql" }, { @@ -1321,7 +1389,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 676, + "snapshotLine": 815, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1331,7 +1399,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 734, + "snapshotLine": 873, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1347,7 +1415,7 @@ "negative_share", "splits_required" ], - "snapshotLine": 754, + "snapshotLine": 893, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1366,7 +1434,7 @@ "splits_sum_mismatch", "zero_shares" ], - "snapshotLine": 831, + "snapshotLine": 970, "definedIn": "supabase/migrations/20261105071511_rpc_expenses.sql" }, { @@ -1376,7 +1444,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 947, + "snapshotLine": 1086, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1386,7 +1454,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 955, + "snapshotLine": 1094, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1396,7 +1464,7 @@ "securityDefiner": true, "execute": [], "raises": ["expense_not_found", "not_allowed"], - "snapshotLine": 1036, + "snapshotLine": 1175, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1406,7 +1474,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1063, + "snapshotLine": 1202, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1416,7 +1484,7 @@ "securityDefiner": false, "execute": [], "raises": ["duplicate_slot", "invalid_slot", "invalid_slots"], - "snapshotLine": 1144, + "snapshotLine": 1283, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1426,7 +1494,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1210, + "snapshotLine": 1349, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1436,7 +1504,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1238, + "snapshotLine": 1377, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1446,7 +1514,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1278, + "snapshotLine": 1417, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1456,7 +1524,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1308, + "snapshotLine": 1447, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1466,7 +1534,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1337, + "snapshotLine": 1476, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1476,7 +1544,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1388, + "snapshotLine": 1527, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1486,7 +1554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1434, + "snapshotLine": 1573, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1496,7 +1564,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1480, + "snapshotLine": 1619, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1506,7 +1574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1538, + "snapshotLine": 1677, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1516,7 +1584,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1692, + "snapshotLine": 1831, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -1526,7 +1594,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1711, + "snapshotLine": 1850, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1536,7 +1604,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1726, + "snapshotLine": 1865, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1546,7 +1614,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1740, + "snapshotLine": 1879, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1556,7 +1624,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1755, + "snapshotLine": 1894, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1566,7 +1634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1772, + "snapshotLine": 1911, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1576,7 +1644,7 @@ "securityDefiner": true, "execute": [], "raises": ["not_a_friend"], - "snapshotLine": 1798, + "snapshotLine": 1937, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1586,7 +1654,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1840, + "snapshotLine": 1979, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1596,7 +1664,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1873, + "snapshotLine": 2012, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -1606,7 +1674,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 1890, + "snapshotLine": 2029, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1616,7 +1684,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1901, + "snapshotLine": 2040, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1626,7 +1694,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1943, + "snapshotLine": 2082, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1636,7 +1704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1973, + "snapshotLine": 2112, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1646,7 +1714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1999, + "snapshotLine": 2138, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1656,7 +1724,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2013, + "snapshotLine": 2152, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1666,7 +1734,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2026, + "snapshotLine": 2165, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1676,7 +1744,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["group_currency_locked"], - "snapshotLine": 2050, + "snapshotLine": 2189, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1686,7 +1754,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_has_balances"], - "snapshotLine": 2072, + "snapshotLine": 2211, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1696,7 +1764,7 @@ "securityDefiner": true, "execute": [], "raises": ["rsvp_closed"], - "snapshotLine": 2098, + "snapshotLine": 2237, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1706,7 +1774,7 @@ "securityDefiner": true, "execute": [], "raises": ["placeholder_member"], - "snapshotLine": 2138, + "snapshotLine": 2277, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1716,7 +1784,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2196, + "snapshotLine": 2335, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1726,7 +1794,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2212, + "snapshotLine": 2351, "definedIn": "supabase/migrations/20261105071417_profiles.sql" }, { @@ -1736,7 +1804,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2244, + "snapshotLine": 2383, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1746,7 +1814,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2256, + "snapshotLine": 2395, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1756,7 +1824,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2273, + "snapshotLine": 2412, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1766,7 +1834,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2289, + "snapshotLine": 2428, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1776,7 +1844,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2305, + "snapshotLine": 2444, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1786,7 +1854,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2322, + "snapshotLine": 2461, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1796,7 +1864,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2337, + "snapshotLine": 2476, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1806,7 +1874,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2350, + "snapshotLine": 2489, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1816,7 +1884,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2379, + "snapshotLine": 2518, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -1826,7 +1894,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2406, + "snapshotLine": 2545, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1836,7 +1904,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2424, + "snapshotLine": 2563, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1846,7 +1914,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2439, + "snapshotLine": 2578, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1856,7 +1924,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 1077, + "snapshotLine": 1216, "definedIn": "supabase/migrations/20261105071434_plans_tables.sql" }, { @@ -1866,7 +1934,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["duplicate_user", "invalid_items", "items_required", "zero_shares"], - "snapshotLine": 2450, + "snapshotLine": 2589, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1876,7 +1944,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2548, + "snapshotLine": 2687, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1886,7 +1954,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2559, + "snapshotLine": 2698, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1896,7 +1964,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found", "not_allowed"], - "snapshotLine": 2577, + "snapshotLine": 2716, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1906,7 +1974,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2603, + "snapshotLine": 2742, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1916,7 +1984,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2617, + "snapshotLine": 2756, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1926,7 +1994,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2725, + "snapshotLine": 2864, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1936,7 +2004,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found"], - "snapshotLine": 2739, + "snapshotLine": 2878, "definedIn": "supabase/migrations/20261105071518_rpc_availability.sql" }, { @@ -1946,7 +2014,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2759, + "snapshotLine": 2898, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -1956,7 +2024,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 939, + "snapshotLine": 1078, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -1966,7 +2034,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2775, + "snapshotLine": 2914, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1976,7 +2044,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2841, + "snapshotLine": 2980, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -1986,7 +2054,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 2857, + "snapshotLine": 2996, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1996,7 +2064,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2871, + "snapshotLine": 3010, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -2006,7 +2074,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2886, + "snapshotLine": 3025, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2016,7 +2084,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2900, + "snapshotLine": 3039, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2026,7 +2094,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2914, + "snapshotLine": 3053, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2036,7 +2104,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2929, + "snapshotLine": 3068, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2046,7 +2114,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2946, + "snapshotLine": 3085, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2056,7 +2124,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2986, + "snapshotLine": 3125, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2066,7 +2134,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3004, + "snapshotLine": 3143, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -2076,7 +2144,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3219, + "snapshotLine": 3358, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2086,7 +2154,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3257, + "snapshotLine": 3396, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2096,7 +2164,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_currency"], - "snapshotLine": 3282, + "snapshotLine": 3421, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2106,7 +2174,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3300, + "snapshotLine": 3439, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2116,7 +2184,7 @@ "securityDefiner": true, "execute": [], "raises": ["fx_rate_unavailable"], - "snapshotLine": 3311, + "snapshotLine": 3450, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2126,7 +2194,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_payment_handle"], - "snapshotLine": 3332, + "snapshotLine": 3471, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -2136,7 +2204,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["note_too_long"], - "snapshotLine": 3352, + "snapshotLine": 3491, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2146,7 +2214,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3370, + "snapshotLine": 3509, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2156,7 +2224,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_email"], - "snapshotLine": 3384, + "snapshotLine": 3523, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2166,7 +2234,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_name"], - "snapshotLine": 3402, + "snapshotLine": 3541, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2176,7 +2244,7 @@ "securityDefiner": true, "execute": [], "raises": ["profile_without_account"], - "snapshotLine": 3420, + "snapshotLine": 3559, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2186,7 +2254,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3439, + "snapshotLine": 3578, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2196,7 +2264,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3471, + "snapshotLine": 3610, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2206,7 +2274,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3490, + "snapshotLine": 3629, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2216,9 +2284,19 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3514, + "snapshotLine": 3653, "definedIn": "supabase/migrations/20261105071410_extensions_and_private_schema.sql" }, + { + "name": "private.receipt_content_type", + "args": "file_path text", + "returns": "text", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 3664, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "private.recovery_code_hash", "args": "code text", @@ -2226,7 +2304,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3525, + "snapshotLine": 3690, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -2236,7 +2314,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3539, + "snapshotLine": 3704, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2246,7 +2324,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3552, + "snapshotLine": 3717, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2256,7 +2334,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3564, + "snapshotLine": 3729, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2266,7 +2344,7 @@ "securityDefiner": false, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 3579, + "snapshotLine": 3744, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2276,7 +2354,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3595, + "snapshotLine": 3760, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2286,7 +2364,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3617, + "snapshotLine": 3782, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2296,7 +2374,7 @@ "securityDefiner": true, "execute": [], "raises": ["split_member_not_in_group"], - "snapshotLine": 3632, + "snapshotLine": 3797, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2306,7 +2384,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3651, + "snapshotLine": 3816, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2316,7 +2394,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3679, + "snapshotLine": 3844, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2326,7 +2404,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3701, + "snapshotLine": 3866, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2336,7 +2414,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3734, + "snapshotLine": 3899, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -2346,7 +2424,7 @@ "securityDefiner": true, "execute": [], "raises": ["direct_group", "not_allowed"], - "snapshotLine": 3776, + "snapshotLine": 3941, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2356,7 +2434,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 3800, + "snapshotLine": 3965, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2366,7 +2444,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_deleted"], - "snapshotLine": 3815, + "snapshotLine": 3980, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2376,7 +2454,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_deleted", "not_a_member"], - "snapshotLine": 3831, + "snapshotLine": 3996, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2386,7 +2464,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 3858, + "snapshotLine": 4023, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2396,7 +2474,7 @@ "securityDefiner": true, "execute": [], "raises": ["account_deleted", "not_authenticated"], - "snapshotLine": 3873, + "snapshotLine": 4038, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -2406,7 +2484,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3894, + "snapshotLine": 4059, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2416,7 +2494,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 3911, + "snapshotLine": 4076, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2446,7 +2524,7 @@ "splits_required", "splits_sum_mismatch" ], - "snapshotLine": 3927, + "snapshotLine": 4092, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2456,7 +2534,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4431, + "snapshotLine": 4596, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2466,7 +2544,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4450, + "snapshotLine": 4615, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2476,7 +2554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4636, + "snapshotLine": 4801, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -2486,7 +2564,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4687, + "snapshotLine": 4852, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2496,7 +2574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4720, + "snapshotLine": 4885, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2506,7 +2584,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4801, + "snapshotLine": 4966, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2516,7 +2594,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4818, + "snapshotLine": 4983, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2526,7 +2604,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4858, + "snapshotLine": 5023, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2536,7 +2614,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4884, + "snapshotLine": 5049, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2546,7 +2624,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 4928, + "snapshotLine": 5093, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -2556,7 +2634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4944, + "snapshotLine": 5109, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2566,7 +2644,7 @@ "securityDefiner": true, "execute": [], "raises": ["too_many_invites"], - "snapshotLine": 4980, + "snapshotLine": 5145, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2576,9 +2654,19 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5000, + "snapshotLine": 5165, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, + { + "name": "private.sync_receipt_attachment", + "args": "", + "returns": "trigger", + "securityDefiner": true, + "execute": ["PUBLIC"], + "raises": [], + "snapshotLine": 5185, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, { "name": "private.sync_single_payer", "args": "", @@ -2586,7 +2674,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5020, + "snapshotLine": 5230, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2596,7 +2684,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5038, + "snapshotLine": 5248, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2606,7 +2694,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5066, + "snapshotLine": 5276, "definedIn": "supabase/migrations/20261105071410_extensions_and_private_schema.sql" }, { @@ -2616,7 +2704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5082, + "snapshotLine": 5292, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2626,7 +2714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5102, + "snapshotLine": 5312, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2636,7 +2724,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5115, + "snapshotLine": 5325, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2646,8 +2734,18 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5126, + "snapshotLine": 5336, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" + }, + { + "name": "private.write_attachments", + "args": "eid uuid, gid uuid, list jsonb", + "returns": "void", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 5360, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" } ], "tables": [ @@ -3054,6 +3152,30 @@ { "name": "events: two-step sign in done", "command": "SELECT", "roles": ["authenticated"] } ] }, + { + "name": "public.expense_attachments", + "kind": "table", + "rls": true, + "realtime": false, + "clientGrants": { + "authenticated": [ + "SELECT(expense_id)", + "SELECT(position)", + "SELECT(group_id)", + "SELECT(path)", + "SELECT(content_type)", + "SELECT(uploaded_by)", + "SELECT(created_at)" + ] + }, + "policies": [ + { + "name": "expense_attachments: members read", + "command": "SELECT", + "roles": ["authenticated"] + } + ] + }, { "name": "public.expense_items", "kind": "table", @@ -3467,6 +3589,12 @@ "raisedBy": ["public.remind_debt"], "i18n": ["expenses.errors.already_reminded"] }, + { + "code": "attachment_not_found", + "sqlstates": ["PT404"], + "raisedBy": ["private.apply_attachments"], + "i18n": ["expenses.errors.attachment_not_found"] + }, { "code": "blocked", "sqlstates": ["42501"], @@ -3563,13 +3691,18 @@ { "code": "expense_deleted", "sqlstates": ["23514"], - "raisedBy": ["private.save_expense", "public.make_expense_recurring"], + "raisedBy": [ + "private.apply_attachments", + "private.save_expense", + "public.make_expense_recurring" + ], "i18n": ["expenses.errors.expense_deleted"] }, { "code": "expense_not_found", "sqlstates": ["PT404"], "raisedBy": [ + "private.apply_attachments", "private.editable_expense", "private.save_expense", "public.make_expense_recurring", @@ -3855,6 +3988,7 @@ "code": "invalid_payload", "sqlstates": ["22023"], "raisedBy": [ + "private.apply_attachments", "private.save_expense", "public.email_unsubscribe", "public.make_expense_recurring", @@ -3879,7 +4013,7 @@ { "code": "invalid_receipt_path", "sqlstates": ["22023"], - "raisedBy": ["private.save_expense"], + "raisedBy": ["private.apply_attachments", "private.save_expense"], "i18n": ["expenses.errors.invalid_receipt_path"] }, { @@ -4050,6 +4184,7 @@ "code": "not_allowed", "sqlstates": ["42501"], "raisedBy": [ + "private.apply_attachments", "private.comments_guard_body", "private.editable_expense", "private.manageable_event", @@ -4255,6 +4390,12 @@ "raisedBy": ["public.schedule_event"], "i18n": ["events.errors.starts_at_required"] }, + { + "code": "too_many_attachments", + "sqlstates": ["22023"], + "raisedBy": ["private.apply_attachments"], + "i18n": ["expenses.errors.too_many_attachments"] + }, { "code": "too_many_invites", "sqlstates": ["23514"], @@ -4355,6 +4496,7 @@ { "name": "ics-feed", "dir": "supabase/functions/ics-feed", "verifyJwt": false }, { "name": "mfa-recover", "dir": "supabase/functions/mfa-recover", "verifyJwt": true }, { "name": "notify", "dir": "supabase/functions/notify", "verifyJwt": false }, + { "name": "receipts-sweep", "dir": "supabase/functions/receipts-sweep", "verifyJwt": false }, { "name": "unsubscribe", "dir": "supabase/functions/unsubscribe", "verifyJwt": false } ] } diff --git a/docs/architecture.md b/docs/architecture.md index 8562393..6564df9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -790,6 +790,7 @@ People not on Potluck yet. Notes for changing anything that touches seats, claim Read before a migration that touches one of these. The general rules and the RPC skeleton are in `supabase/AGENTS.md`; seats are [above](#seats). - Deleted groups ([ADR 20](adr/0020-group-types-restorable-deletes-and-shared-notes.md)): `delete_group` only stamps `groups.deleted_at`. `my_group_ids`, `is_group_member`, `is_group_admin`, `group_role`, `require_member`, `my_event_ids` and `can_manage_event` hide stamped groups, so policies and RPCs built on them need nothing more. Code that reads `group_members` or `groups` directly must add `g.deleted_at is null`; inserting expenses, payments or members into a deleted group raises `group_deleted`. `purge-deleted-groups` removes them after 30 days (test 28). +- Expense files ([ADR 21](adr/0021-receipt-scanning-and-attachments.md)): `expense_attachments` rows are written only by `set_expense_attachments`, `create_expense_with_attachments` and the `receipt_path` mirror trigger; the `receipts` bucket policies stay the only storage rules (a deleted group's files vanish with it from `my_group_ids`). The sweep and pgTAP storage deletes: [receipts](receipts.md). - Payers: expense payers live in `expense_payers` (deferred sum check). A trigger keeps a lone payer in step with `expenses.paid_by`, so an insert that only sets `paid_by` is still right; several payers are written by `private.save_expense`. Balances and departed checks read payers, not `paid_by` ([ADR 12](adr/0012-several-payers-refunds-and-stored-items.md)). - Repeating expenses ([ADR 14](adr/0014-editable-recurring-templates.md)): a template keeps the `create_expense_v2` payload shape (`splits`, `payers`, `items` with tax, tip, discount) and only shapes future copies. Dates step through `private.recurring_step` (core `nextOccurrence`); "who it names and whether they all still belong" is `private.recurring_departed` / `recurring_blocked`, shared by the job, the heads-up and the resume trigger; who may change one is `private.recurring_involves` (RPCs and policies alike). The heads-up job runs at :05 and :35, per person (`profiles.timezone`), deduplicated by `private.recurring_heads_ups`. Edits go through `update_recurring_expense`, never new direct grants. - Guests ([ADR 16](adr/0016-guest-availability-links.md)) are rows, not users: availability reads (`best_slots`, heat) count `event_guest_availability` too. diff --git a/docs/development.md b/docs/development.md index 88b673b..4325e1d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -300,6 +300,7 @@ Notes for changing particular screens in `apps/web`. The general rules are in `a - Categories: pick with `CategoryPicker` (`features/expenses/form/category-picker.tsx`, top level chips plus a row of subcategories) and label with `categoryLabel()`. A new expense's category follows core `suggestCategory` (group history from `categoryHistoryQueryOptions`) until someone taps one. Subcategories are stored as their own ids; totals and charts roll them up with core `parentCategory()`. - Groups ([ADR 20](adr/0020-group-types-restorable-deletes-and-shared-notes.md)): a deleted group is gone from every query for 30 days; "Recently deleted" (`features/groups/recently-deleted.tsx`) reads `my_deleted_groups`, and its old address shows the deleted page with Restore (`MissingGroup`). Covers render through `GroupCover` / `coverSrc()` (public URL, works signed out). The Notes tab (`notes-page.tsx`) saves with a base version and subscribes to `group_whiteboards`, refetching through `useRealtime`'s `onSubscribed` once the channel joins (an edit saved while it was joining sends no change event, and every channel asks Realtime to hold `SUBSCRIBED` until Postgres changes flow with `postgres_changes_options.wait`; the plan page's `useEventRealtime` does the same for paints and guest answers); keep that pattern for any other shared text. Long names with no spaces use `wrap-anywhere`, not `break-words`, so they cannot widen a grid column or the phone page. +- Receipts (`features/expenses/receipts`, [ADR 21](adr/0021-receipt-scanning-and-attachments.md)): only `ocr.ts` imports `tesseract.js`, lazily; a scan only fills the form (the merchant becomes the description, so the category suggestion follows it). Full rules in [receipts](receipts.md). - Plan pages add guests from `/a/$code` (guest links, signed out) to `members` with `guest: true`. - Money: `fx_rate` arrives as decimal text; pass it to `convert()` unchanged. An expense can have several payers and be a refund ([ADR 12](adr/0012-several-payers-refunds-and-stored-items.md)): read who paid with `payersOf()` and feed core with `toBalanceExpense()` (`features/expenses/expense-math.ts`), never `paid_by` alone. What an expense means for the viewer (`viewerLine`) comes from core `expenseEffect` in the group currency, so a row can never disagree with the balance. - Repeating expenses (`features/recurring/`, [ADR 14](adr/0014-editable-recurring-templates.md)): a template opens in the expense form (`ExpenseForm mode="recurring"`, route `/recurring/$recurringId`) through `templateAsExpense()`, so payers, adjustments and items work as for an expense. Skip, pause and stop live in `useTemplateActions`; the expense page shows `RepeatCard`. diff --git a/docs/receipts.md b/docs/receipts.md index 5051b35..9b32704 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -34,4 +34,5 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The - `receipt_path` is always the first attachment. A trigger mirrors an older client's writes: its file is replaced or dropped and the others stay. - Dropped files are deleted by the client when the bucket lets it (uploader or admin). Files uploaded for a save that never happened are deleted when the form closes, and kept for a retry while it is open. - The daily `receipts-sweep` job finds files in the bucket older than a day that no expense or attachment names (`private.orphan_receipts`) and has the `receipts-sweep` edge function delete them through the Storage API, which also removes the stored object. Deleting rows from `storage.objects` in SQL would leave the object behind. +- A deleted group's files stay while it can be restored (its expenses still name them). When `purge-deleted-groups` removes it after 30 days, its rows go and the next sweep deletes the files. - pgTAP deletes from `storage.objects` need `set_config('storage.allow_delete_query', 'true', true)`, as the Storage API sets. diff --git a/packages/api/src/database.types.ts b/packages/api/src/database.types.ts index c271c75..f55e6e1 100644 --- a/packages/api/src/database.types.ts +++ b/packages/api/src/database.types.ts @@ -1956,6 +1956,39 @@ export type Database = { isSetofReturn: false } } + create_expense_with_attachments: { + Args: { attachments: Json; payload: Json } + Returns: { + amount_cents: number + category: string | null + created_at: string + created_by: string | null + currency: string + deleted_at: string | null + description: string + discount_cents: number | null + event_id: string | null + fx_rate: number + group_id: string + id: string + is_refund: boolean + notes: string | null + paid_by: string + receipt_path: string | null + recurring_id: string | null + spent_at: string + split_type: Database['public']['Enums']['split_type'] + tax_cents: number | null + tip_cents: number | null + updated_at: string + } + SetofOptions: { + from: '*' + to: 'expenses' + isOneToOne: true + isSetofReturn: false + } + } create_friend_invite: { Args: { email: string } Returns: { diff --git a/supabase/migrations/20261113173404_expense_attachments.sql b/supabase/migrations/20261114015104_expense_attachments.sql similarity index 100% rename from supabase/migrations/20261113173404_expense_attachments.sql rename to supabase/migrations/20261114015104_expense_attachments.sql diff --git a/supabase/schema/current.sql b/supabase/schema/current.sql index b2e1ca1..b74df94 100644 --- a/supabase/schema/current.sql +++ b/supabase/schema/current.sql @@ -138,6 +138,100 @@ $$; ALTER FUNCTION "private"."add_pair_debt"("owed" "jsonb", "debtor" "uuid", "creditor" "uuid", "cents" numeric) OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "private"."apply_attachments"("eid" "uuid", "attachments" "jsonb", "log_edit" boolean) RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +#variable_conflict use_column +declare + uid uuid := private.require_user(); + my_role public.group_role; + gid uuid; + prev public.expenses; + old_list jsonb; + new_list jsonb := '[]'::jsonb; + entry jsonb; + item jsonb; + file_path text; +begin + if attachments is null or jsonb_typeof(attachments) <> 'array' then + raise exception using message = 'invalid_payload', detail = 'Attachments must be a list', errcode = '22023'; + end if; + + select e.group_id into gid from public.expenses e where e.id = eid; + if gid is null then + raise exception using message = 'expense_not_found', detail = 'Expense not found', errcode = 'PT404'; + end if; + perform 1 from public.groups g where g.id = gid for share; + my_role := private.require_member(gid); + perform private.require_group_open(gid); + + select * into prev from public.expenses e where e.id = eid for update; + if my_role = 'member' and uid is distinct from prev.created_by and uid <> prev.paid_by + and not exists (select 1 from public.expense_payers y where y.expense_id = eid and y.user_id = uid) then + raise exception using message = 'not_allowed', detail = 'You can only edit your own expenses', errcode = '42501'; + end if; + if prev.deleted_at is not null then + raise exception using message = 'expense_deleted', detail = 'Restore this expense before editing it', errcode = '23514'; + end if; + if jsonb_array_length(attachments) > 5 then + raise exception using message = 'too_many_attachments', detail = 'An expense holds up to five attachments', errcode = '22023'; + end if; + + old_list := private.attachment_list(eid); + for entry in select x.v from jsonb_array_elements(attachments) as x (v) loop + file_path := case jsonb_typeof(entry) + when 'string' then entry #>> '{}' + when 'object' then entry ->> 'path' + end; + if file_path is null or file_path not like gid::text || '/%' or file_path like '%..%' + or char_length(file_path) > 300 + or exists (select 1 from jsonb_array_elements(new_list) as n (v) where n.v ->> 'path' = file_path) then + raise exception using message = 'invalid_receipt_path', detail = 'Receipts must be stored under the group folder', errcode = '22023'; + end if; + -- A file already on the expense keeps who added it and when; a new one must be uploaded. + select o.v into item from jsonb_array_elements(old_list) as o (v) where o.v ->> 'path' = file_path; + if item is null then + if not exists ( + select 1 from storage.objects o where o.bucket_id = 'receipts' and o.name = file_path + ) then + raise exception using message = 'attachment_not_found', detail = 'That file has not been uploaded', errcode = 'PT404'; + end if; + item := private.attachment_item(file_path, uid); + end if; + new_list := new_list || jsonb_build_array(item); + end loop; + + if (select coalesce(jsonb_agg(x.v ->> 'path'), '[]'::jsonb) from jsonb_array_elements(new_list) as x (v)) + is distinct from + (select coalesce(jsonb_agg(x.v ->> 'path'), '[]'::jsonb) from jsonb_array_elements(old_list) as x (v)) then + perform private.write_attachments(eid, gid, new_list); + -- The first file is the receipt older clients show. The row is touched even when it stays + -- the same, so realtime tells everyone looking at the expense. This function logs the edit. + perform set_config('potluck.expense_logged', eid::text, true); + update public.expenses e set receipt_path = new_list -> 0 ->> 'path' where e.id = eid; + if not log_edit then + return; + end if; + perform private.log_activity(gid, uid, 'expense.updated', eid, jsonb_build_object( + 'description', prev.description, + 'amount_cents', prev.amount_cents, + 'currency', prev.currency, + 'paid_by', prev.paid_by, + 'is_refund', prev.is_refund, + 'changes', jsonb_build_object('attachments', jsonb_build_object( + 'from', jsonb_array_length(old_list), + 'to', jsonb_array_length(new_list) + )) + )); + end if; +end; +$$; + + +ALTER FUNCTION "private"."apply_attachments"("eid" "uuid", "attachments" "jsonb", "log_edit" boolean) OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "private"."ascii_lower"("value" "text") RETURNS "text" LANGUAGE "sql" IMMUTABLE SET "search_path" TO '' @@ -149,6 +243,51 @@ $$; ALTER FUNCTION "private"."ascii_lower"("value" "text") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "private"."attachment_item"("file_path" "text", "who" "uuid") RETURNS "jsonb" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + select jsonb_build_object( + 'path', file_path, + 'content_type', private.receipt_content_type(file_path), + 'uploaded_by', ( + select p.id from public.profiles p + where p.id = coalesce( + (select private.try_uuid(o.owner_id) from storage.objects o + where o.bucket_id = 'receipts' and o.name = file_path), + who + ) + ), + 'created_at', now() + ); +$$; + + +ALTER FUNCTION "private"."attachment_item"("file_path" "text", "who" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "private"."attachment_list"("eid" "uuid") RETURNS "jsonb" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + select coalesce( + jsonb_agg( + jsonb_build_object( + 'path', a.path, 'content_type', a.content_type, + 'uploaded_by', a.uploaded_by, 'created_at', a.created_at + ) + order by a.position + ), + '[]'::jsonb + ) + from public.expense_attachments a + where a.expense_id = eid; +$$; + + +ALTER FUNCTION "private"."attachment_list"("eid" "uuid") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "private"."befriend"("a" "uuid", "b" "uuid") RETURNS "void" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -3522,6 +3661,32 @@ $$; ALTER FUNCTION "private"."random_code"("bytes" integer) OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "private"."receipt_content_type"("file_path" "text") RETURNS "text" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $_$ + select coalesce( + ( + select o.metadata ->> 'mimetype' from storage.objects o + where o.bucket_id = 'receipts' and o.name = file_path + and o.metadata ->> 'mimetype' in + ('image/png', 'image/jpeg', 'image/webp', 'image/heic', 'application/pdf') + ), + case lower(substring(file_path from '\.([A-Za-z0-9]+)$')) + when 'pdf' then 'application/pdf' + when 'png' then 'image/png' + when 'webp' then 'image/webp' + when 'heic' then 'image/heic' + when 'heif' then 'image/heic' + else 'image/jpeg' + end + ); +$_$; + + +ALTER FUNCTION "private"."receipt_content_type"("file_path" "text") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "private"."recovery_code_hash"("code" "text") RETURNS "text" LANGUAGE "sql" IMMUTABLE SET "search_path" TO '' @@ -5017,6 +5182,51 @@ $$; ALTER FUNCTION "private"."sync_account_email"() OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "private"."sync_receipt_attachment"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +declare + list jsonb; + kept jsonb; +begin + if tg_op = 'INSERT' then + if new.receipt_path is not null then + perform private.write_attachments(new.id, new.group_id, + jsonb_build_array(private.attachment_item(new.receipt_path, new.created_by))); + end if; + return null; + end if; + + if new.receipt_path is not distinct from old.receipt_path then + return new; + end if; + list := private.attachment_list(new.id); + if new.receipt_path is not distinct from (list -> 0 ->> 'path') then + return new; + end if; + + select coalesce(jsonb_agg(x.item order by x.ord), '[]'::jsonb) into kept + from jsonb_array_elements(list) with ordinality as x (item, ord) + where (x.item ->> 'path') is distinct from old.receipt_path + and (x.item ->> 'path') is distinct from new.receipt_path; + if new.receipt_path is not null then + kept := jsonb_build_array(private.attachment_item(new.receipt_path, auth.uid())) || kept; + end if; + select coalesce(jsonb_agg(x.item order by x.ord), '[]'::jsonb) into kept + from jsonb_array_elements(kept) with ordinality as x (item, ord) + where x.ord <= 5; + + perform private.write_attachments(new.id, new.group_id, kept); + new.receipt_path := kept -> 0 ->> 'path'; + return new; +end; +$$; + + +ALTER FUNCTION "private"."sync_receipt_attachment"() OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "private"."sync_single_payer"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -5147,6 +5357,24 @@ $_$; ALTER FUNCTION "private"."valid_payment_handle"("kind" "text", "handle" "text") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "private"."write_attachments"("eid" "uuid", "gid" "uuid", "list" "jsonb") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +begin + delete from public.expense_attachments a where a.expense_id = eid; + insert into public.expense_attachments + (expense_id, position, group_id, path, content_type, uploaded_by, created_at) + select eid, (x.ord - 1)::smallint, gid, x.item ->> 'path', x.item ->> 'content_type', + (x.item ->> 'uploaded_by')::uuid, coalesce((x.item ->> 'created_at')::timestamptz, now()) + from jsonb_array_elements(coalesce(list, '[]'::jsonb)) with ordinality as x (item, ord); +end; +$$; + + +ALTER FUNCTION "private"."write_attachments"("eid" "uuid", "gid" "uuid", "list" "jsonb") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "public"."accept_friend_invite"("code" "text") RETURNS "uuid" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -5587,6 +5815,24 @@ $$; ALTER FUNCTION "public"."create_expense_v2"("payload" "jsonb") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "public"."create_expense_with_attachments"("payload" "jsonb", "attachments" "jsonb") RETURNS "public"."expenses" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +declare + created public.expenses; +begin + select * into created from private.save_expense(null, payload); + perform private.apply_attachments(created.id, attachments, false); + select * into created from public.expenses e where e.id = created.id; + return created; +end; +$$; + + +ALTER FUNCTION "public"."create_expense_with_attachments"("payload" "jsonb", "attachments" "jsonb") OWNER TO "postgres"; + + CREATE TABLE IF NOT EXISTS "public"."friend_invites" ( "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, "code" "text" DEFAULT "private"."random_code"(9) NOT NULL, @@ -7783,6 +8029,23 @@ $$; ALTER FUNCTION "public"."notify_mfa_recovered"("target" "uuid") OWNER TO "postgres"; +CREATE OR REPLACE FUNCTION "public"."orphan_receipts"("max_count" integer DEFAULT 500) RETURNS SETOF "text" + LANGUAGE "sql" STABLE SECURITY DEFINER + SET "search_path" TO '' + AS $$ + select o.name from storage.objects o + where o.bucket_id = 'receipts' + and o.created_at < now() - interval '1 day' + and not exists (select 1 from public.expenses e where e.receipt_path = o.name) + and not exists (select 1 from public.expense_attachments a where a.path = o.name) + order by o.created_at + limit greatest(1, least(coalesce(max_count, 500), 1000)); +$$; + + +ALTER FUNCTION "public"."orphan_receipts"("max_count" integer) OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "public"."record_mfa_recover_attempt"("target" "uuid") RETURNS boolean LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -8839,6 +9102,45 @@ $$; ALTER FUNCTION "public"."set_default_split"("group_id" "uuid", "split" "jsonb") OWNER TO "postgres"; +CREATE TABLE IF NOT EXISTS "public"."expense_attachments" ( + "expense_id" "uuid" NOT NULL, + "position" smallint NOT NULL, + "group_id" "uuid" NOT NULL, + "path" "text" NOT NULL, + "content_type" "text" NOT NULL, + "uploaded_by" "uuid", + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + CONSTRAINT "expense_attachments_content_type_check" CHECK (("content_type" = ANY (ARRAY['image/png'::"text", 'image/jpeg'::"text", 'image/webp'::"text", 'image/heic'::"text", 'application/pdf'::"text"]))), + CONSTRAINT "expense_attachments_in_group_folder" CHECK (("path" ~~ (("group_id")::"text" || '/%'::"text"))), + CONSTRAINT "expense_attachments_path_check" CHECK (("char_length"("path") <= 300)), + CONSTRAINT "expense_attachments_position_check" CHECK ((("position" >= 0) AND ("position" < 5))) +); + + +ALTER TABLE "public"."expense_attachments" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."expense_attachments" IS 'Receipts, photos and PDFs on an expense, in order (files in the receipts bucket). The first is also expenses.receipt_path.'; + + + +CREATE OR REPLACE FUNCTION "public"."set_expense_attachments"("id" "uuid", "attachments" "jsonb") RETURNS SETOF "public"."expense_attachments" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +begin + perform private.apply_attachments(set_expense_attachments.id, attachments, true); + return query + select a.* from public.expense_attachments a + where a.expense_id = set_expense_attachments.id + order by a.position; +end; +$$; + + +ALTER FUNCTION "public"."set_expense_attachments"("id" "uuid", "attachments" "jsonb") OWNER TO "postgres"; + + CREATE OR REPLACE FUNCTION "public"."set_guest_availability"("code" "text", "name" "text", "token" "text", "slots" "jsonb") RETURNS TABLE("guest_token" "text", "saved" integer) LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -11062,6 +11364,16 @@ ALTER TABLE ONLY "public"."events" +ALTER TABLE ONLY "public"."expense_attachments" + ADD CONSTRAINT "expense_attachments_expense_id_path_key" UNIQUE ("expense_id", "path"); + + + +ALTER TABLE ONLY "public"."expense_attachments" + ADD CONSTRAINT "expense_attachments_pkey" PRIMARY KEY ("expense_id", "position"); + + + ALTER TABLE ONLY "public"."expense_items" ADD CONSTRAINT "expense_items_pkey" PRIMARY KEY ("expense_id", "position"); @@ -11332,6 +11644,14 @@ CREATE INDEX "events_starts_at_idx" ON "public"."events" USING "btree" ("starts_ +CREATE INDEX "expense_attachments_group_idx" ON "public"."expense_attachments" USING "btree" ("group_id"); + + + +CREATE INDEX "expense_attachments_uploaded_by_idx" ON "public"."expense_attachments" USING "btree" ("uploaded_by"); + + + CREATE INDEX "expense_payers_user_id_idx" ON "public"."expense_payers" USING "btree" ("user_id"); @@ -11596,6 +11916,14 @@ CREATE CONSTRAINT TRIGGER "expenses_payers_sum_check" AFTER INSERT OR UPDATE OF +CREATE OR REPLACE TRIGGER "expenses_receipt_attachment_insert" AFTER INSERT ON "public"."expenses" FOR EACH ROW EXECUTE FUNCTION "private"."sync_receipt_attachment"(); + + + +CREATE OR REPLACE TRIGGER "expenses_receipt_attachment_update" BEFORE UPDATE OF "receipt_path" ON "public"."expenses" FOR EACH ROW EXECUTE FUNCTION "private"."sync_receipt_attachment"(); + + + CREATE OR REPLACE TRIGGER "expenses_require_live_group" BEFORE INSERT ON "public"."expenses" FOR EACH ROW EXECUTE FUNCTION "private"."require_live_group"(); @@ -11994,6 +12322,21 @@ ALTER TABLE ONLY "public"."events" +ALTER TABLE ONLY "public"."expense_attachments" + ADD CONSTRAINT "expense_attachments_expense_id_fkey" FOREIGN KEY ("expense_id") REFERENCES "public"."expenses"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."expense_attachments" + ADD CONSTRAINT "expense_attachments_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."expense_attachments" + ADD CONSTRAINT "expense_attachments_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "public"."profiles"("id") ON DELETE SET NULL; + + + ALTER TABLE ONLY "public"."expense_items" ADD CONSTRAINT "expense_items_expense_id_fkey" FOREIGN KEY ("expense_id") REFERENCES "public"."expenses"("id") ON DELETE CASCADE; @@ -12411,6 +12754,13 @@ CREATE POLICY "events: two-step sign in done" ON "public"."events" AS RESTRICTIV +ALTER TABLE "public"."expense_attachments" ENABLE ROW LEVEL SECURITY; + + +CREATE POLICY "expense_attachments: members read" ON "public"."expense_attachments" FOR SELECT TO "authenticated" USING (("group_id" = ANY (ARRAY( SELECT "private"."my_group_ids"() AS "my_group_ids")))); + + + ALTER TABLE "public"."expense_items" ENABLE ROW LEVEL SECURITY; @@ -12666,10 +13016,22 @@ GRANT USAGE ON SCHEMA "public" TO "service_role"; +REVOKE ALL ON FUNCTION "private"."apply_attachments"("eid" "uuid", "attachments" "jsonb", "log_edit" boolean) FROM PUBLIC; + + + REVOKE ALL ON FUNCTION "private"."ascii_lower"("value" "text") FROM PUBLIC; +REVOKE ALL ON FUNCTION "private"."attachment_item"("file_path" "text", "who" "uuid") FROM PUBLIC; + + + +REVOKE ALL ON FUNCTION "private"."attachment_list"("eid" "uuid") FROM PUBLIC; + + + REVOKE ALL ON FUNCTION "private"."befriend"("a" "uuid", "b" "uuid") FROM PUBLIC; @@ -13097,6 +13459,10 @@ REVOKE ALL ON FUNCTION "private"."queue_email"() FROM PUBLIC; +REVOKE ALL ON FUNCTION "private"."receipt_content_type"("file_path" "text") FROM PUBLIC; + + + REVOKE ALL ON FUNCTION "private"."recovery_code_hash"("code" "text") FROM PUBLIC; @@ -13245,6 +13611,10 @@ GRANT ALL ON FUNCTION "private"."valid_payment_handle"("kind" "text", "handle" " +REVOKE ALL ON FUNCTION "private"."write_attachments"("eid" "uuid", "gid" "uuid", "list" "jsonb") FROM PUBLIC; + + + REVOKE ALL ON FUNCTION "public"."accept_friend_invite"("code" "text") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."accept_friend_invite"("code" "text") TO "authenticated"; GRANT ALL ON FUNCTION "public"."accept_friend_invite"("code" "text") TO "service_role"; @@ -13311,6 +13681,12 @@ GRANT ALL ON FUNCTION "public"."create_expense_v2"("payload" "jsonb") TO "servic +REVOKE ALL ON FUNCTION "public"."create_expense_with_attachments"("payload" "jsonb", "attachments" "jsonb") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."create_expense_with_attachments"("payload" "jsonb", "attachments" "jsonb") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_expense_with_attachments"("payload" "jsonb", "attachments" "jsonb") TO "service_role"; + + + GRANT ALL ON TABLE "public"."friend_invites" TO "service_role"; @@ -13624,6 +14000,11 @@ GRANT ALL ON FUNCTION "public"."notify_mfa_recovered"("target" "uuid") TO "servi +REVOKE ALL ON FUNCTION "public"."orphan_receipts"("max_count" integer) FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."orphan_receipts"("max_count" integer) TO "service_role"; + + + REVOKE ALL ON FUNCTION "public"."record_mfa_recover_attempt"("target" "uuid") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."record_mfa_recover_attempt"("target" "uuid") TO "service_role"; @@ -13789,6 +14170,44 @@ GRANT ALL ON FUNCTION "public"."set_default_split"("group_id" "uuid", "split" "j +GRANT ALL ON TABLE "public"."expense_attachments" TO "service_role"; + + + +GRANT SELECT("expense_id") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("position") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("group_id") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("path") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("content_type") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("uploaded_by") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +GRANT SELECT("created_at") ON TABLE "public"."expense_attachments" TO "authenticated"; + + + +REVOKE ALL ON FUNCTION "public"."set_expense_attachments"("id" "uuid", "attachments" "jsonb") FROM PUBLIC; +GRANT ALL ON FUNCTION "public"."set_expense_attachments"("id" "uuid", "attachments" "jsonb") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."set_expense_attachments"("id" "uuid", "attachments" "jsonb") TO "service_role"; + + + REVOKE ALL ON FUNCTION "public"."set_guest_availability"("code" "text", "name" "text", "token" "text", "slots" "jsonb") FROM PUBLIC; GRANT ALL ON FUNCTION "public"."set_guest_availability"("code" "text", "name" "text", "token" "text", "slots" "jsonb") TO "anon"; GRANT ALL ON FUNCTION "public"."set_guest_availability"("code" "text", "name" "text", "token" "text", "slots" "jsonb") TO "authenticated"; @@ -14253,4 +14672,5 @@ ALTER PUBLICATION "supabase_realtime" ADD TABLE ONLY "public"."settlements"; -- cron purge-deleted-groups '20 3 * * *': select private.purge_deleted_groups() -- cron purge-import-batches '*/15 * * * *': delete from private.import_batches where created_at < now() - interval '1 hour' -- cron purge-invite-sends '20 3 * * *': delete from private.invite_sends where sent_at < now() - interval '7 days' +-- cron receipts-sweep '40 3 * * *': select private.invoke_edge_function('receipts-sweep') -- cron recurring-heads-up '5,35 * * * *': select private.send_recurring_heads_ups() diff --git a/supabase/tests/01_schema.test.sql b/supabase/tests/01_schema.test.sql index 277b3c0..90fac53 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -10,10 +10,7 @@ -- new cron job -> a cron.job check -- Count the assertions and update plan(N); finish() fails when it is off. begin; -select plan(126); -select plan(98); -select plan(100); -select plan(101); +select plan(130); select tables_are('public', array[ 'profiles', 'friendships', 'blocks', 'reports', @@ -271,6 +268,8 @@ select ok( select ok( exists (select 1 from cron.job where jobname = 'purge-import-batches' and schedule = '*/15 * * * *'), 'unfinished imports are cleared every quarter hour' +); +select ok( exists (select 1 from cron.job where jobname = 'receipts-sweep' and schedule = '40 3 * * *'), 'orphaned receipt files are swept daily' ); From 6a8c884e0abc614d9b29fd1469ddd65b2376facf Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:57:31 -0500 Subject: [PATCH 19/29] receipts: trust a matching card line, no filler line for a cent, cap 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> --- .../expenses/receipts/attachment-gallery.tsx | 1 - .../src/features/expenses/receipts/ocr.test.ts | 13 +++++++++++-- apps/web/src/features/expenses/receipts/ocr.ts | 18 +++++++++++++++++- .../expenses/receipts/scan-fill.test.ts | 10 ++++++++++ .../features/expenses/receipts/scan-fill.ts | 3 ++- .../expenses/receipts/scan-receipt.tsx | 2 +- .../features/expenses/receipts/use-zoom-pan.ts | 4 +++- .../expenses/receipts/zoomable-image.tsx | 8 ++++++-- .../core/src/receipts/parse-receipt.test.ts | 11 +++++++++++ packages/core/src/receipts/parse-receipt.ts | 7 +++++++ 10 files changed, 68 insertions(+), 9 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx index 521eacd..1fc4a54 100644 --- a/apps/web/src/features/expenses/receipts/attachment-gallery.tsx +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -223,7 +223,6 @@ function Viewer({
    ) : url ? ( 1 ? onMove : undefined} diff --git a/apps/web/src/features/expenses/receipts/ocr.test.ts b/apps/web/src/features/expenses/receipts/ocr.test.ts index 3448704..86826f4 100644 --- a/apps/web/src/features/expenses/receipts/ocr.test.ts +++ b/apps/web/src/features/expenses/receipts/ocr.test.ts @@ -21,10 +21,12 @@ vi.mock('tesseract.js', () => ({ tess.logger = options.logger options.logger({ status: 'loading tesseract core', progress: 0.5 }) return { - recognize: vi.fn(async (image: unknown) => { + recognize: vi.fn((image: unknown) => { tess.seen.push(image) tess.logger?.({ status: 'recognizing text', progress: 1 }) - return { data: { text: tess.texts.shift() ?? '' } } + const text = tess.texts.shift() ?? '' + // 'slow' never finishes, like a long colour reading on a slow phone. + return text === 'slow' ? new Promise(() => {}) : Promise.resolve({ data: { text } }) }), terminate: tess.terminate, } @@ -74,6 +76,13 @@ describe('readReceiptText', () => { expect(tess.seen).toEqual(['native', 'closer', photo]) }) + it('drops a last reading that takes too long and keeps the others', async () => { + tess.texts = ['first', 'second', 'slow'] + const texts = await readReceiptText(photo, { enough: () => false, lastPassMs: 10 }) + expect(texts).toEqual(['first', 'second']) + expect(tess.terminate).toHaveBeenCalled() + }) + it('never reads the same photo twice', async () => { // The photo could not be decoded, so the first reading already used the original. copies.first = photo diff --git a/apps/web/src/features/expenses/receipts/ocr.ts b/apps/web/src/features/expenses/receipts/ocr.ts index d4a88ce..791010e 100644 --- a/apps/web/src/features/expenses/receipts/ocr.ts +++ b/apps/web/src/features/expenses/receipts/ocr.ts @@ -10,6 +10,9 @@ export interface ReadProgress { progress: number } +/** How long the last, colour reading may take (a long receipt on a slow phone). */ +const LAST_PASS_MS = 8000 + /** The reader could not start: almost always the first scan without a connection. */ export class ReaderUnavailableError extends Error { constructor(cause: unknown) { @@ -35,10 +38,13 @@ export async function readReceiptText( onProgress, signal, enough = () => true, + lastPassMs = LAST_PASS_MS, }: { onProgress?: (p: ReadProgress) => void signal?: AbortSignal enough?: (texts: string[]) => boolean + /** The colour reading is a last try: past this it is dropped and the readings so far stand. */ + lastPassMs?: number } = {}, ): Promise { signal?.throwIfAborted() @@ -78,7 +84,17 @@ export async function readReceiptText( // A copy that could not be made, or the same photo again, adds nothing. if (!blob || read.has(blob)) continue read.add(blob) - const { data } = await Promise.race([worker.recognize(blob), stopped]) + const limit = + index === images.length - 1 + ? new Promise((resolve) => setTimeout(() => resolve(null), lastPassMs)) + : null + const result = await Promise.race([ + worker.recognize(blob), + stopped, + ...(limit ? [limit] : []), + ]) + if (!result) break + const { data } = result texts.push(data.text) if (enough(texts)) break } diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index 40b742a..b9d9674 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -96,6 +96,16 @@ describe('fillFromScan', () => { expect(fill.amount).toBe(5617) }) + it('adds no line for a gap of a cent or so', () => { + const fill = fillFromScan(scan({ totalCents: 5618 }), all, { + draft, + currency: 'USD', + memberIds: people, + }) + expect(fill.draft?.items.map((i) => i.name)).toEqual(['2× Cheeseburger', 'Fries']) + expect(fill.amount).toBe(5617) + }) + it('uses only the people already in the split', () => { const two = { ...draft, participants: ['me', 'ben'] } const fill = fillFromScan(scan(), all, { draft: two, currency: 'USD', memberIds: people }) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts index bfc800e..1438016 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -99,7 +99,8 @@ export function fillFromScan( } const missing = choice.total && scan.totalCents !== null ? scan.totalCents - itemsTotal(next) : 0 - if (missing > 0) { + // A gap of a cent or so is a misread digit, not a forgotten line: no "Everything else" for it. + if (missing > 0 && missing * 100 > (scan.totalCents ?? 0)) { next.items = [ ...items, { ...newItem([...people]), name: t('expenses.scan.rest'), amountCents: missing }, diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index 5dc7d20..6e658f2 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -447,7 +447,7 @@ function Review({ : item.description return (
  • - {name} + {name} {price(name, item.amountCents, (cents) => edit({ items: scan.items.map((x, j) => diff --git a/apps/web/src/features/expenses/receipts/use-zoom-pan.ts b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts index d43f4c8..4d02c71 100644 --- a/apps/web/src/features/expenses/receipts/use-zoom-pan.ts +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts @@ -1,4 +1,5 @@ import { + useCallback, useRef, useState, type CSSProperties, @@ -147,6 +148,7 @@ export function useZoomPan({ onSwipe }: { onSwipe?: (step: 1 | -1) => void } = { toggleAt(e.clientX, e.clientY) } + const reset = useCallback(() => setView({ scale: 1, x: 0, y: 0 }), []) const step = (factor: number) => settle(zoomAt(view, view.scale * factor, 0, 0)) // Keyboard: + and - zoom, 0 resets, arrows move a zoomed photo. Unzoomed, arrows are left to @@ -197,6 +199,6 @@ export function useZoomPan({ onSwipe }: { onSwipe?: (step: 1 | -1) => void } = { }, zoomIn: () => step(1.5), zoomOut: () => step(1 / 1.5), - reset: () => setView({ scale: 1, x: 0, y: 0 }), + reset, } } diff --git a/apps/web/src/features/expenses/receipts/zoomable-image.tsx b/apps/web/src/features/expenses/receipts/zoomable-image.tsx index ecb8a84..186d5c5 100644 --- a/apps/web/src/features/expenses/receipts/zoomable-image.tsx +++ b/apps/web/src/features/expenses/receipts/zoomable-image.tsx @@ -1,13 +1,13 @@ import { t } from '@potluck/i18n' import { IconButton, cn } from '@potluck/ui' import { ZoomIn, ZoomOut } from 'lucide-react' -import { useId } from 'react' +import { useEffect, useId } from 'react' import { MAX_ZOOM, useZoomPan } from './use-zoom-pan' /** * A photo that pinches, double taps and drags to zoom. The zoom buttons sit under it, never on * the print; with the photo focused, + and - zoom and the arrow keys move it. Swiping sideways - * when it is not zoomed calls `onSwipe`. Key it by `src` so a new photo starts unzoomed. + * when it is not zoomed calls `onSwipe`. A new `src` starts unzoomed. */ export function ZoomableImage({ src, @@ -26,6 +26,10 @@ export function ZoomableImage({ }) { const zoom = useZoomPan({ onSwipe }) const hint = useId() + // A new photo starts unzoomed. The frame stays mounted, so focus stays on the button that + // moved to it. + const { reset } = zoom + useEffect(() => reset(), [src, reset]) return (
    { expect(match('Shop\nTotal 10.00')).toBeNull() }) + it('trusts the card line when it matches the items and the total line was misread', () => { + const scan = parseReceipt('Shop\nRice 2.99\nGarlic 0.99\nTOTAL $4.08\nVISA $3.98', { + currency: 'USD', + }) + expect(scan.totalCents).toBe(398) + expect(scan.itemsMatch).toBe('exact') + // A cash line that matches nothing changes nothing. + const cash = parseReceipt('Shop\nRice 2.99\nTOTAL 4.08\nCash 10.00', { currency: 'USD' }) + expect(cash.totalCents).toBe(408) + }) + it('drops a line dearer than the whole bill', () => { const scan = parseReceipt('Shop\nGarlic 81.89\nRice 2.99\nTotal 76.50', { currency: 'USD' }) expect(scan.items.map((i) => i.description)).toEqual(['Rice']) diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 6125ae2..f5d1e2a 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -412,6 +412,8 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // The line the last item came from, and a unit price line still waiting for its item. let lastItemLine = -2 let pendingQty: { count: number | null; unit: number } | null = null + // What the card or cash lines say was paid. + const paid: number[] = [] for (const [index, line] of lines.entries()) { const key = keys[index]! @@ -447,6 +449,7 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip discount = (discount ?? 0) + price.cents break case 'payment': + paid.push(price.cents) itemsEnd = Math.min(itemsEnd, index) break case 'item': { @@ -508,6 +511,10 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip else if (sums(null, null, null)) [tax, tip, discount] = [null, null, null] } } + // A total line read a few cents off, while the card line says exactly what the items say: the + // card line was the bill ("TOTAL $76.59" over "VISA $76.50" on a blurred photo). + const formSum = itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) + if (offered.length > 0 && total !== formSum && paid.includes(formSum)) total = formSum return { merchant: findMerchant(lines), From dff9894bf81ddd06bd595d27d2a2f5fc55ef9a6d Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:44:20 -0500 Subject: [PATCH 20/29] guard unloaded attachments, payment totals, in-flight uploads --- .../form/expense-form-receipts.test.tsx | 142 +++++- .../features/expenses/form/expense-form.tsx | 35 +- .../expenses/receipts/attachments-field.tsx | 119 +++-- docs/agent-map.json | 466 +++++++++--------- docs/receipts.md | 4 +- docs/security.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 13 + packages/core/src/receipts/parse-receipt.ts | 3 +- packages/i18n/src/de/money.ts | 3 + packages/i18n/src/en/money.ts | 2 + packages/i18n/src/es/money.ts | 2 + packages/i18n/src/fr/money.ts | 3 + packages/i18n/src/pt/money.ts | 2 + .../20261114015104_expense_attachments.sql | 5 +- supabase/schema/current.sql | 1 - .../tests/29_expense_attachments.test.sql | 17 +- 16 files changed, 517 insertions(+), 302 deletions(-) diff --git a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx index ece8d8a..e0a8550 100644 --- a/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx @@ -15,12 +15,22 @@ import type * as Ocr from '../receipts/ocr' import { BEN, CLEO, GROUP_ID, ME, expense, renderWithClient, seededClient } from '../test-utils' import { ExpenseForm } from './expense-form' +const listFiles = vi.hoisted(() => vi.fn<(expenseId: string) => Promise>()) + vi.mock('@/lib/supabase', () => import('../fake-supabase')) vi.mock('@/lib/auth', () => ({ useUserId: () => 'aaaaaaaa-0000-4000-8000-000000000001' })) vi.mock('@potluck/api', async (importOriginal) => { let n = 0 + const api = await importOriginal() return { - ...(await importOriginal()), + ...api, + // The stored list, for an edit whose files are not in the cache yet. + attachmentQueries: { + list: (_c: unknown, expenseId: string) => ({ + queryKey: api.attachmentKeys.list(expenseId), + queryFn: () => listFiles(expenseId), + }), + }, createExpense: vi.fn(async () => ({ id: 'new-expense' })), createExpenseWithAttachments: vi.fn(async () => ({ id: 'new-expense' })), updateExpense: vi.fn(async (_c: unknown, id: string) => ({ id })), @@ -48,8 +58,43 @@ const addFiles = async (files: File[]) => { }) } +const storedFile = (expenseId: string, name: string, position = 0) => ({ + expense_id: expenseId, + position, + path: `${GROUP_ID}/${name}`, + content_type: 'image/jpeg', + uploaded_by: ME, + created_at: '2026-11-01T00:00:00Z', +}) + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const fillNewExpense = () => { + fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '20' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { + target: { value: 'Lunch' }, + }) +} + +const scanPhoto = async () => { + const input = document.querySelector('input[type=file][accept="image/*"]') as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { files: [photo('scan.jpg')] } }) + }) + return screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) +} + beforeEach(() => { vi.clearAllMocks() + listFiles.mockReset() URL.createObjectURL = vi.fn(() => 'blob:photo') URL.revokeObjectURL = vi.fn() }) @@ -245,4 +290,99 @@ describe('expense form files', () => { { description: 'Horchata', amount_cents: 400, user_ids: [ME, BEN, CLEO] }, ]) }) + it('keeps the files and the scan from changing anything while the stored list loads', async () => { + listFiles.mockReturnValue(new Promise(() => {})) + const onDone = vi.fn() + renderWithClient( + {}} + />, + ) + expect( + await screen.findByRole('status', { name: t('expenses.attachments.loading') }), + ).toBeVisible() + expect(screen.queryByRole('button', { name: t('expenses.attachments.addLabel') })).toBeNull() + + const sheet = await scanPhoto() + expect(within(sheet).queryByRole('checkbox', { name: t('expenses.scan.attach') })).toBeNull() + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('e4')) + expect(vi.mocked(uploadReceipt)).not.toHaveBeenCalled() + expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() + }) + + it('says when the stored list failed to load, changes nothing, and tries again', async () => { + listFiles.mockRejectedValue(new Error('offline')) + const onDone = vi.fn() + renderWithClient( + {}} + />, + ) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(t('expenses.attachments.loadFailed')) + expect(screen.queryByRole('button', { name: t('expenses.attachments.addLabel') })).toBeNull() + + const sheet = await scanPhoto() + expect(within(sheet).queryByRole('checkbox', { name: t('expenses.scan.attach') })).toBeNull() + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('e5')) + expect(vi.mocked(setExpenseAttachments)).not.toHaveBeenCalled() + + listFiles.mockResolvedValue([storedFile('e5', 'a.jpg')]) + fireEvent.click(await screen.findByRole('button', { name: t('common.retry') })) + expect( + await screen.findByRole('img', { name: t('expenses.attachments.photo', { number: 1 }) }), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: t('expenses.attachments.addLabel') })).toBeVisible() + }) + + it('leaves the uploads of a save still running when the form closes', async () => { + const call = deferred<{ id: string }>() + vi.mocked(createExpenseWithAttachments).mockReturnValueOnce(call.promise as never) + const view = renderWithClient( + {}} onClose={() => {}} />, + ) + fillNewExpense() + await addFiles([photo('a.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(vi.mocked(createExpenseWithAttachments)).toHaveBeenCalledTimes(1)) + const paths = vi.mocked(createExpenseWithAttachments).mock.calls[0]![2] + view.unmount() + expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith(expect.anything(), paths) + + await act(async () => call.resolve({ id: 'new-expense' })) + expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith(expect.anything(), paths) + }) + + it('deletes the uploads once a save that outlived the form fails', async () => { + const call = deferred<{ id: string }>() + vi.mocked(createExpenseWithAttachments).mockReturnValueOnce(call.promise as never) + const view = renderWithClient( + {}} onClose={() => {}} />, + ) + fillNewExpense() + await addFiles([photo('a.jpg')]) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(vi.mocked(createExpenseWithAttachments)).toHaveBeenCalledTimes(1)) + const paths = vi.mocked(createExpenseWithAttachments).mock.calls[0]![2] + view.unmount() + expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith(expect.anything(), paths) + + await act(async () => call.reject({ message: 'fx_rate_unavailable' })) + await waitFor(() => + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), paths), + ) + }) }) diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index 1663eef..2bf4dc7 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -287,14 +287,24 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { enabled: !!expense, }) const [attachments, setAttachments] = useState(expense ? null : []) + // Until the stored list is in, the files cannot be changed: an empty list would replace them. + const filesReady = !expense || storedFiles.data !== undefined const fileDrafts = attachments ?? draftsFromStored(storedFiles.data ?? []) // Files uploaded for a save that has not gone through yet: reused on a retry, deleted if the - // form closes without saving. + // form closes without saving. A save still running when the form closes cleans up after itself. const uploaded = useRef(new Map()) + const closed = useRef(false) + const saveRunning = useRef(false) + const dropUploads = () => { + const unused = [...uploaded.current.values()] + uploaded.current.clear() + if (unused.length > 0) void removeReceiptFiles(supabase, unused) + } useEffect(() => { - const pending = uploaded.current + closed.current = false return () => { - if (pending.size > 0) void removeReceiptFiles(supabase, [...pending.values()]) + closed.current = true + if (!saveRunning.current) dropUploads() } }, []) const [recurrence, setRecurrence] = useState( @@ -388,6 +398,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { }) const save = useMutation({ + onMutate: () => { + saveRunning.current = true + }, + onSettled: () => { + saveRunning.current = false + if (closed.current) dropUploads() + }, mutationFn: async () => { const stored = expense ? (storedFiles.data ?? []) : [] const filesChanged = !!attachments && attachmentsChanged(attachments, stored) @@ -514,7 +531,7 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { } if (fill.amount !== undefined) setAmount(fill.amount) if (fill.draft) setDraft(fill.draft) - if (photo) setAttachments(addDrafts(fileDrafts, [photo]).drafts) + if (photo && filesReady) setAttachments(addDrafts(fileDrafts, [photo]).drafts) if (fill.amount !== undefined || fill.draft) toast.success(t('expenses.scan.applied')) } @@ -650,7 +667,7 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { draft={draft} memberIds={memberIds} timeZone={timeZone} - canAttach={fileDrafts.length < MAX_ATTACHMENTS} + canAttach={filesReady && fileDrafts.length < MAX_ATTACHMENTS} onApply={applyScan} /> )} @@ -859,7 +876,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { {props.mode === 'recurring' && props.actions} ) : ( - + void storedFiles.refetch()} + retrying={storedFiles.isFetching} + /> )} {!editing && !refund && ( void + status?: 'ready' | 'loading' | 'failed' + onRetry?: () => void + retrying?: boolean }) { const inputRef = useRef(null) const id = useId() @@ -75,53 +85,66 @@ export function AttachmentsField({
    - { - const files = [...(e.target.files ?? [])] - e.target.value = '' - void pick(files) - }} - /> -
      - {value.map((draft, index) => ( - onChange(value.filter((d) => d.key !== draft.key))} + {status === 'loading' && } + {status === 'failed' && ( +
      +

      {t('expenses.attachments.loadFailed')}

      + +
      + )} + {status === 'ready' && ( + <> + { + const files = [...(e.target.files ?? [])] + e.target.value = '' + void pick(files) + }} /> - ))} - {Array.from({ length: preparing }, (_, i) => ( -
    • - -
    • - ))} - {!full && ( -
    • - -
    • - )} -
    - {full && value.length >= MAX_ATTACHMENTS && ( -

    {t('expenses.attachments.full')}

    +
      + {value.map((draft, index) => ( + onChange(value.filter((d) => d.key !== draft.key))} + /> + ))} + {Array.from({ length: preparing }, (_, i) => ( +
    • + +
    • + ))} + {!full && ( +
    • + +
    • + )} +
    + {full && value.length >= MAX_ATTACHMENTS && ( +

    {t('expenses.attachments.full')}

    + )} + )}
    ) diff --git a/docs/agent-map.json b/docs/agent-map.json index 82ea873..3dbe6b7 100644 --- a/docs/agent-map.json +++ b/docs/agent-map.json @@ -266,7 +266,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5378, + "snapshotLine": 5377, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -284,7 +284,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5433, + "snapshotLine": 5432, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -294,7 +294,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "not_a_friend"], - "snapshotLine": 5503, + "snapshotLine": 5502, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -304,7 +304,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["already_member", "placeholder_exists"], - "snapshotLine": 5530, + "snapshotLine": 5529, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -314,7 +314,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5608, + "snapshotLine": 5607, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -324,7 +324,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "person_not_found"], - "snapshotLine": 5677, + "snapshotLine": 5676, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -334,7 +334,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 5714, + "snapshotLine": 5713, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -344,7 +344,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_done"], - "snapshotLine": 5743, + "snapshotLine": 5742, "definedIn": "supabase/migrations/20261105071521_rpc_events_timeline.sql" }, { @@ -354,7 +354,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5770, + "snapshotLine": 5769, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -364,7 +364,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5793, + "snapshotLine": 5792, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -374,7 +374,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5807, + "snapshotLine": 5806, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -384,7 +384,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5818, + "snapshotLine": 5817, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -394,7 +394,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_email", "invalid_friend", "too_many_invites"], - "snapshotLine": 5860, + "snapshotLine": 5859, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -404,7 +404,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency"], - "snapshotLine": 5943, + "snapshotLine": 5942, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -414,7 +414,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_name_required", "invalid_currency", "trip_dates_invalid"], - "snapshotLine": 5981, + "snapshotLine": 5980, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -429,7 +429,7 @@ "invalid_guest_limit", "invalid_guest_link_ttl" ], - "snapshotLine": 6049, + "snapshotLine": 6048, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -446,7 +446,7 @@ "invalid_role", "not_allowed" ], - "snapshotLine": 6124, + "snapshotLine": 6123, "definedIn": "supabase/migrations/20261105071508_rpc_invites.sql" }, { @@ -456,7 +456,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 6178, + "snapshotLine": 6177, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -466,7 +466,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 6215, + "snapshotLine": 6214, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -476,7 +476,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["direct_group_not_deletable", "group_has_balances", "not_allowed"], - "snapshotLine": 6253, + "snapshotLine": 6252, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -486,7 +486,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 6304, + "snapshotLine": 6303, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -496,7 +496,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 6408, + "snapshotLine": 6407, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -506,7 +506,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor"], - "snapshotLine": 6453, + "snapshotLine": 6452, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -516,7 +516,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6544, + "snapshotLine": 6543, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -526,7 +526,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invalid_payload", "invalid_unsubscribe_link"], - "snapshotLine": 6579, + "snapshotLine": 6578, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -536,7 +536,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6669, + "snapshotLine": 6668, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -546,7 +546,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6747, + "snapshotLine": 6746, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -556,7 +556,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_friend"], - "snapshotLine": 6758, + "snapshotLine": 6757, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -566,7 +566,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6863, + "snapshotLine": 6862, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -576,7 +576,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 6901, + "snapshotLine": 6900, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -586,7 +586,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_friend", "not_a_friend"], - "snapshotLine": 6950, + "snapshotLine": 6949, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -596,7 +596,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["guest_link_not_found"], - "snapshotLine": 7011, + "snapshotLine": 7010, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -606,7 +606,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import", "not_a_friend"], - "snapshotLine": 7121, + "snapshotLine": 7120, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -616,7 +616,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["import_too_large", "invalid_import"], - "snapshotLine": 7457, + "snapshotLine": 7456, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -626,7 +626,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7518, + "snapshotLine": 7517, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -636,7 +636,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": ["invite_not_found"], - "snapshotLine": 7577, + "snapshotLine": 7576, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -646,7 +646,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "owner_must_transfer"], - "snapshotLine": 7669, + "snapshotLine": 7668, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -663,7 +663,7 @@ "not_allowed", "split_member_not_in_group" ], - "snapshotLine": 7724, + "snapshotLine": 7723, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -673,7 +673,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7841, + "snapshotLine": 7840, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -683,7 +683,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7855, + "snapshotLine": 7854, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -693,7 +693,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7866, + "snapshotLine": 7865, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -703,7 +703,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7887, + "snapshotLine": 7886, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -713,7 +713,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7905, + "snapshotLine": 7904, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -723,7 +723,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7938, + "snapshotLine": 7937, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -733,7 +733,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7964, + "snapshotLine": 7963, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -743,7 +743,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8019, + "snapshotLine": 8018, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -753,7 +753,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8032, + "snapshotLine": 8031, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -763,7 +763,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 8049, + "snapshotLine": 8048, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -779,7 +779,7 @@ "note_too_long", "nothing_owed" ], - "snapshotLine": 8096, + "snapshotLine": 8095, "definedIn": "supabase/migrations/20261113115303_debt_reminders.sql" }, { @@ -789,7 +789,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_found", "placeholder_already_reminded", "placeholder_no_email"], - "snapshotLine": 8160, + "snapshotLine": 8159, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -799,7 +799,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["friend_has_balance"], - "snapshotLine": 8203, + "snapshotLine": 8202, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -809,7 +809,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_not_found"], - "snapshotLine": 8230, + "snapshotLine": 8229, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -825,7 +825,7 @@ "not_allowed", "use_leave_group" ], - "snapshotLine": 8262, + "snapshotLine": 8261, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -835,7 +835,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_has_balance", "member_not_found", "not_allowed"], - "snapshotLine": 8309, + "snapshotLine": 8308, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -845,7 +845,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_polling", "member_not_found"], - "snapshotLine": 8347, + "snapshotLine": 8346, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -855,7 +855,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["split_member_not_in_group"], - "snapshotLine": 8416, + "snapshotLine": 8415, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -865,7 +865,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["group_not_found", "not_a_member"], - "snapshotLine": 8455, + "snapshotLine": 8454, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -875,7 +875,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["member_not_in_group", "not_allowed", "settlement_not_found"], - "snapshotLine": 8502, + "snapshotLine": 8501, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -885,7 +885,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["guest_link_not_found", "not_allowed"], - "snapshotLine": 8547, + "snapshotLine": 8546, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -895,7 +895,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["whiteboard_conflict", "whiteboard_too_long"], - "snapshotLine": 8598, + "snapshotLine": 8597, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -905,7 +905,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_not_schedulable", "starts_at_required"], - "snapshotLine": 8659, + "snapshotLine": 8658, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -915,7 +915,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor", "invalid_date_range", "invalid_query"], - "snapshotLine": 8693, + "snapshotLine": 8692, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -925,7 +925,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["duplicate_slot", "event_not_polling", "invalid_slot", "invalid_slots"], - "snapshotLine": 8955, + "snapshotLine": 8954, "definedIn": "supabase/migrations/20261106020000_availability_lock.sql" }, { @@ -935,7 +935,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_splits", "split_member_not_in_group"], - "snapshotLine": 9044, + "snapshotLine": 9043, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -945,7 +945,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9127, + "snapshotLine": 9126, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -966,7 +966,7 @@ "guest_slots_required", "invalid_guest_name" ], - "snapshotLine": 9144, + "snapshotLine": 9143, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -981,7 +981,7 @@ "not_allowed", "use_transfer_ownership" ], - "snapshotLine": 9234, + "snapshotLine": 9233, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -991,7 +991,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_notification_category", "notification_setting_required"], - "snapshotLine": 9274, + "snapshotLine": 9273, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -1001,7 +1001,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9311, + "snapshotLine": 9310, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -1017,7 +1017,7 @@ "invalid_recipient", "member_not_found" ], - "snapshotLine": 9350, + "snapshotLine": 9349, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1027,7 +1027,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9408, + "snapshotLine": 9407, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1037,7 +1037,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_amount", "invalid_parties", "invalid_payload", "nothing_owed"], - "snapshotLine": 9445, + "snapshotLine": 9444, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1047,7 +1047,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9529, + "snapshotLine": 9528, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -1057,7 +1057,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_allowed", "recurring_not_found"], - "snapshotLine": 9589, + "snapshotLine": 9588, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1067,7 +1067,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_currency", "invalid_date_range"], - "snapshotLine": 9638, + "snapshotLine": 9637, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1077,7 +1077,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["already_owner", "member_not_found", "not_allowed"], - "snapshotLine": 9783, + "snapshotLine": 9782, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1087,7 +1087,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9821, + "snapshotLine": 9820, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1097,7 +1097,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9840, + "snapshotLine": 9839, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1113,7 +1113,7 @@ "placeholder_exists", "too_many_invites" ], - "snapshotLine": 9856, + "snapshotLine": 9855, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1138,7 +1138,7 @@ "split_member_not_in_group", "splits_sum_mismatch" ], - "snapshotLine": 9959, + "snapshotLine": 9958, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1154,7 +1154,7 @@ "settlement_deleted", "settlement_not_found" ], - "snapshotLine": 10216, + "snapshotLine": 10215, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" } ], @@ -1194,7 +1194,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 235, + "snapshotLine": 234, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1204,7 +1204,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 246, + "snapshotLine": 245, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -1214,7 +1214,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 269, + "snapshotLine": 268, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -1224,7 +1224,7 @@ "securityDefiner": true, "execute": [], "raises": ["blocked"], - "snapshotLine": 291, + "snapshotLine": 290, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1234,7 +1234,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 309, + "snapshotLine": 308, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1244,7 +1244,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 342, + "snapshotLine": 341, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1254,7 +1254,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 362, + "snapshotLine": 361, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1264,7 +1264,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 381, + "snapshotLine": 380, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1274,7 +1274,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["payers_sum_mismatch"], - "snapshotLine": 393, + "snapshotLine": 392, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1284,7 +1284,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["splits_sum_mismatch"], - "snapshotLine": 433, + "snapshotLine": 432, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1294,7 +1294,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["group_owner_required"], - "snapshotLine": 474, + "snapshotLine": 473, "definedIn": "supabase/migrations/20261105071420_groups_members_invites.sql" }, { @@ -1304,7 +1304,7 @@ "securityDefiner": true, "execute": [], "raises": ["invalid_amount", "invalid_parties", "member_not_in_group", "not_a_party"], - "snapshotLine": 513, + "snapshotLine": 512, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1314,7 +1314,7 @@ "securityDefiner": false, "execute": ["anon", "authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 545, + "snapshotLine": 544, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -1329,7 +1329,7 @@ "placeholder_claim_conflict", "placeholder_not_found" ], - "snapshotLine": 563, + "snapshotLine": 562, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1339,7 +1339,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 707, + "snapshotLine": 706, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1349,7 +1349,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 745, + "snapshotLine": 744, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1359,7 +1359,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 756, + "snapshotLine": 755, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1369,7 +1369,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 777, + "snapshotLine": 776, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1379,7 +1379,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["not_allowed"], - "snapshotLine": 797, + "snapshotLine": 796, "definedIn": "supabase/migrations/20261105071501_rls_platform.sql" }, { @@ -1389,7 +1389,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 815, + "snapshotLine": 814, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1399,7 +1399,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 873, + "snapshotLine": 872, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1415,7 +1415,7 @@ "negative_share", "splits_required" ], - "snapshotLine": 893, + "snapshotLine": 892, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1434,7 +1434,7 @@ "splits_sum_mismatch", "zero_shares" ], - "snapshotLine": 970, + "snapshotLine": 969, "definedIn": "supabase/migrations/20261105071511_rpc_expenses.sql" }, { @@ -1444,7 +1444,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 1086, + "snapshotLine": 1085, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1454,7 +1454,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1094, + "snapshotLine": 1093, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1464,7 +1464,7 @@ "securityDefiner": true, "execute": [], "raises": ["expense_not_found", "not_allowed"], - "snapshotLine": 1175, + "snapshotLine": 1174, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1474,7 +1474,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1202, + "snapshotLine": 1201, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1484,7 +1484,7 @@ "securityDefiner": false, "execute": [], "raises": ["duplicate_slot", "invalid_slot", "invalid_slots"], - "snapshotLine": 1283, + "snapshotLine": 1282, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1494,7 +1494,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1349, + "snapshotLine": 1348, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1504,7 +1504,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1377, + "snapshotLine": 1376, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1514,7 +1514,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1417, + "snapshotLine": 1416, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1524,7 +1524,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1447, + "snapshotLine": 1446, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1534,7 +1534,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1476, + "snapshotLine": 1475, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1544,7 +1544,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1527, + "snapshotLine": 1526, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1554,7 +1554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1573, + "snapshotLine": 1572, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1564,7 +1564,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1619, + "snapshotLine": 1618, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1574,7 +1574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1677, + "snapshotLine": 1676, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1584,7 +1584,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1831, + "snapshotLine": 1830, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -1594,7 +1594,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1850, + "snapshotLine": 1849, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1604,7 +1604,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1865, + "snapshotLine": 1864, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1614,7 +1614,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1879, + "snapshotLine": 1878, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1624,7 +1624,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1894, + "snapshotLine": 1893, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1634,7 +1634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1911, + "snapshotLine": 1910, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1644,7 +1644,7 @@ "securityDefiner": true, "execute": [], "raises": ["not_a_friend"], - "snapshotLine": 1937, + "snapshotLine": 1936, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1654,7 +1654,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1979, + "snapshotLine": 1978, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1664,7 +1664,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2012, + "snapshotLine": 2011, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -1674,7 +1674,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 2029, + "snapshotLine": 2028, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1684,7 +1684,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2040, + "snapshotLine": 2039, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1694,7 +1694,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2082, + "snapshotLine": 2081, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1704,7 +1704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2112, + "snapshotLine": 2111, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1714,7 +1714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2138, + "snapshotLine": 2137, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1724,7 +1724,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2152, + "snapshotLine": 2151, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1734,7 +1734,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2165, + "snapshotLine": 2164, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1744,7 +1744,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["group_currency_locked"], - "snapshotLine": 2189, + "snapshotLine": 2188, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1754,7 +1754,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_has_balances"], - "snapshotLine": 2211, + "snapshotLine": 2210, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1764,7 +1764,7 @@ "securityDefiner": true, "execute": [], "raises": ["rsvp_closed"], - "snapshotLine": 2237, + "snapshotLine": 2236, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1774,7 +1774,7 @@ "securityDefiner": true, "execute": [], "raises": ["placeholder_member"], - "snapshotLine": 2277, + "snapshotLine": 2276, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1784,7 +1784,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2335, + "snapshotLine": 2334, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1794,7 +1794,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2351, + "snapshotLine": 2350, "definedIn": "supabase/migrations/20261105071417_profiles.sql" }, { @@ -1804,7 +1804,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2383, + "snapshotLine": 2382, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1814,7 +1814,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2395, + "snapshotLine": 2394, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1824,7 +1824,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2412, + "snapshotLine": 2411, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1834,7 +1834,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2428, + "snapshotLine": 2427, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1844,7 +1844,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2444, + "snapshotLine": 2443, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1854,7 +1854,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2461, + "snapshotLine": 2460, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1864,7 +1864,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2476, + "snapshotLine": 2475, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1874,7 +1874,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2489, + "snapshotLine": 2488, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1884,7 +1884,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2518, + "snapshotLine": 2517, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -1894,7 +1894,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2545, + "snapshotLine": 2544, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1904,7 +1904,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2563, + "snapshotLine": 2562, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1914,7 +1914,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2578, + "snapshotLine": 2577, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1924,7 +1924,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 1216, + "snapshotLine": 1215, "definedIn": "supabase/migrations/20261105071434_plans_tables.sql" }, { @@ -1934,7 +1934,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["duplicate_user", "invalid_items", "items_required", "zero_shares"], - "snapshotLine": 2589, + "snapshotLine": 2588, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1944,7 +1944,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2687, + "snapshotLine": 2686, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1954,7 +1954,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2698, + "snapshotLine": 2697, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1964,7 +1964,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found", "not_allowed"], - "snapshotLine": 2716, + "snapshotLine": 2715, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1974,7 +1974,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2742, + "snapshotLine": 2741, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1984,7 +1984,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2756, + "snapshotLine": 2755, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1994,7 +1994,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2864, + "snapshotLine": 2863, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -2004,7 +2004,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found"], - "snapshotLine": 2878, + "snapshotLine": 2877, "definedIn": "supabase/migrations/20261105071518_rpc_availability.sql" }, { @@ -2014,7 +2014,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2898, + "snapshotLine": 2897, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -2024,7 +2024,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 1078, + "snapshotLine": 1077, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -2034,7 +2034,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2914, + "snapshotLine": 2913, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -2044,7 +2044,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2980, + "snapshotLine": 2979, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -2054,7 +2054,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 2996, + "snapshotLine": 2995, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -2064,7 +2064,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 3010, + "snapshotLine": 3009, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -2074,7 +2074,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 3025, + "snapshotLine": 3024, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2084,7 +2084,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 3039, + "snapshotLine": 3038, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2094,7 +2094,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3053, + "snapshotLine": 3052, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2104,7 +2104,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3068, + "snapshotLine": 3067, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2114,7 +2114,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3085, + "snapshotLine": 3084, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2124,7 +2124,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3125, + "snapshotLine": 3124, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2134,7 +2134,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3143, + "snapshotLine": 3142, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -2144,7 +2144,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3358, + "snapshotLine": 3357, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2154,7 +2154,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3396, + "snapshotLine": 3395, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2164,7 +2164,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_currency"], - "snapshotLine": 3421, + "snapshotLine": 3420, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2174,7 +2174,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3439, + "snapshotLine": 3438, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2184,7 +2184,7 @@ "securityDefiner": true, "execute": [], "raises": ["fx_rate_unavailable"], - "snapshotLine": 3450, + "snapshotLine": 3449, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2194,7 +2194,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_payment_handle"], - "snapshotLine": 3471, + "snapshotLine": 3470, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -2204,7 +2204,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["note_too_long"], - "snapshotLine": 3491, + "snapshotLine": 3490, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2214,7 +2214,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3509, + "snapshotLine": 3508, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2224,7 +2224,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_email"], - "snapshotLine": 3523, + "snapshotLine": 3522, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2234,7 +2234,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_name"], - "snapshotLine": 3541, + "snapshotLine": 3540, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2244,7 +2244,7 @@ "securityDefiner": true, "execute": [], "raises": ["profile_without_account"], - "snapshotLine": 3559, + "snapshotLine": 3558, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2254,7 +2254,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3578, + "snapshotLine": 3577, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2264,7 +2264,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3610, + "snapshotLine": 3609, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2274,7 +2274,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3629, + "snapshotLine": 3628, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2284,7 +2284,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3653, + "snapshotLine": 3652, "definedIn": "supabase/migrations/20261105071410_extensions_and_private_schema.sql" }, { @@ -2294,7 +2294,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3664, + "snapshotLine": 3663, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -2304,7 +2304,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3690, + "snapshotLine": 3689, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -2314,7 +2314,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3704, + "snapshotLine": 3703, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2324,7 +2324,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3717, + "snapshotLine": 3716, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2334,7 +2334,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3729, + "snapshotLine": 3728, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2344,7 +2344,7 @@ "securityDefiner": false, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 3744, + "snapshotLine": 3743, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2354,7 +2354,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3760, + "snapshotLine": 3759, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2364,7 +2364,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3782, + "snapshotLine": 3781, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2374,7 +2374,7 @@ "securityDefiner": true, "execute": [], "raises": ["split_member_not_in_group"], - "snapshotLine": 3797, + "snapshotLine": 3796, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2384,7 +2384,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3816, + "snapshotLine": 3815, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2394,7 +2394,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3844, + "snapshotLine": 3843, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2404,7 +2404,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3866, + "snapshotLine": 3865, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2414,7 +2414,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3899, + "snapshotLine": 3898, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -2424,7 +2424,7 @@ "securityDefiner": true, "execute": [], "raises": ["direct_group", "not_allowed"], - "snapshotLine": 3941, + "snapshotLine": 3940, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2434,7 +2434,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 3965, + "snapshotLine": 3964, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2444,7 +2444,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_deleted"], - "snapshotLine": 3980, + "snapshotLine": 3979, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2454,7 +2454,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_deleted", "not_a_member"], - "snapshotLine": 3996, + "snapshotLine": 3995, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2464,7 +2464,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 4023, + "snapshotLine": 4022, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2474,7 +2474,7 @@ "securityDefiner": true, "execute": [], "raises": ["account_deleted", "not_authenticated"], - "snapshotLine": 4038, + "snapshotLine": 4037, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -2484,7 +2484,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4059, + "snapshotLine": 4058, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2494,7 +2494,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 4076, + "snapshotLine": 4075, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2524,7 +2524,7 @@ "splits_required", "splits_sum_mismatch" ], - "snapshotLine": 4092, + "snapshotLine": 4091, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2534,7 +2534,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4596, + "snapshotLine": 4595, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2544,7 +2544,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4615, + "snapshotLine": 4614, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2554,7 +2554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4801, + "snapshotLine": 4800, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -2564,7 +2564,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4852, + "snapshotLine": 4851, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2574,7 +2574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4885, + "snapshotLine": 4884, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2584,7 +2584,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4966, + "snapshotLine": 4965, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2594,7 +2594,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4983, + "snapshotLine": 4982, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2604,7 +2604,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5023, + "snapshotLine": 5022, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2614,7 +2614,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5049, + "snapshotLine": 5048, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2624,7 +2624,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5093, + "snapshotLine": 5092, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -2634,7 +2634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5109, + "snapshotLine": 5108, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2644,7 +2644,7 @@ "securityDefiner": true, "execute": [], "raises": ["too_many_invites"], - "snapshotLine": 5145, + "snapshotLine": 5144, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2654,7 +2654,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5165, + "snapshotLine": 5164, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2664,7 +2664,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5185, + "snapshotLine": 5184, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" }, { @@ -2674,7 +2674,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5230, + "snapshotLine": 5229, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2684,7 +2684,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5248, + "snapshotLine": 5247, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2694,7 +2694,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5276, + "snapshotLine": 5275, "definedIn": "supabase/migrations/20261105071410_extensions_and_private_schema.sql" }, { @@ -2704,7 +2704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5292, + "snapshotLine": 5291, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2714,7 +2714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5312, + "snapshotLine": 5311, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2724,7 +2724,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5325, + "snapshotLine": 5324, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2734,7 +2734,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5336, + "snapshotLine": 5335, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -2744,7 +2744,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5360, + "snapshotLine": 5359, "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" } ], diff --git a/docs/receipts.md b/docs/receipts.md index 9b32704..8529d60 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -29,10 +29,10 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The ## Attachments in the database - `expense_attachments` holds up to five files per expense, in order. Files live in the private `receipts` bucket under `/`, so the bucket's member, uploader and two-step policies are the only storage rules. -- `set_expense_attachments(id, attachments)`: a list of storage paths (or `{path}` objects). Group row share lock, member, `require_group_open`, own expense or admin, not on a deleted expense. Each path starts with `/`, appears once and exists in the bucket (`attachment_not_found`); more than five is `too_many_attachments`. An unchanged list writes and logs nothing. Otherwise it rewrites the rows, sets `receipt_path` to the first file and logs one `expense.updated` with `changes.attachments = {from, to}`. Every change through it is logged, however soon after the create. +- `set_expense_attachments(id, attachments)`: a list of storage paths (strings; anything else is `invalid_receipt_path`). Group row share lock, member, `require_group_open`, own expense or admin, not on a deleted expense. Each path starts with `/`, appears once and exists in the bucket (`attachment_not_found`); more than five is `too_many_attachments`. An unchanged list writes and logs nothing. Otherwise it rewrites the rows, sets `receipt_path` to the first file and logs one `expense.updated` with `changes.attachments = {from, to}`. Every change through it is logged, however soon after the create. - `create_expense_with_attachments(payload, attachments)`: `create_expense_v2` and the same file checks in one transaction. The files are part of creating the expense, so its history has the one `expense.created` entry, and a refused file means nothing is created. The app creates every expense with files this way. - `receipt_path` is always the first attachment. A trigger mirrors an older client's writes: its file is replaced or dropped and the others stay. - Dropped files are deleted by the client when the bucket lets it (uploader or admin). Files uploaded for a save that never happened are deleted when the form closes, and kept for a retry while it is open. -- The daily `receipts-sweep` job finds files in the bucket older than a day that no expense or attachment names (`private.orphan_receipts`) and has the `receipts-sweep` edge function delete them through the Storage API, which also removes the stored object. Deleting rows from `storage.objects` in SQL would leave the object behind. +- The daily `receipts-sweep` job finds files in the bucket older than a day that no expense or attachment names (`public.orphan_receipts`, service role only) and has the `receipts-sweep` edge function delete them through the Storage API, which also removes the stored object. Deleting rows from `storage.objects` in SQL would leave the object behind. - A deleted group's files stay while it can be restored (its expenses still name them). When `purge-deleted-groups` removes it after 30 days, its rows go and the next sweep deletes the files. - pgTAP deletes from `storage.objects` need `set_config('storage.allow_delete_query', 'true', true)`, as the Storage API sets. diff --git a/docs/security.md b/docs/security.md index 9460360..ef19a39 100644 --- a/docs/security.md +++ b/docs/security.md @@ -40,7 +40,7 @@ Out of scope: a compromised Supabase or Cloudflare account, a malicious maintain These are real today. Each should become an issue. - **Closed accounts keep their calendar feed.** `delete_my_account()` does not delete the `calendar_feed_tokens` row, and `ics-feed` does not check `profiles.deleted_at`. A closed account that still has an open balance in a group keeps that group's membership, so its old feed URL keeps listing that group's events. -- **Removed attachments can linger.** Taking a file off an expense deletes it only when the person removing it uploaded it or is an admin (the bucket's delete rule); otherwise the file stays in the group folder, readable by members, with nothing pointing at it. Deleting an expense keeps its files (it can be restored). +- **Removed attachments can linger.** Taking a file off an expense deletes it only when the person removing it uploaded it or is an admin (the bucket's delete rule); otherwise the file stays in the group folder, readable by members with nothing pointing at it, until the daily `receipts-sweep` deletes it once it is a day old. Deleting an expense keeps its files (it can be restored). - **Avatar files outlive the account.** The profile's `avatar_path` is cleared, but the file stays in the public `avatars` bucket, at a URL anyone who saw it can still open. - **Admin invite rule mismatch.** `can()` in core lets admins create admin invites. The server refuses, so this is a UI bug, not a hole. - **Previews use production data.** PR preview builds point at the production Supabase project. Anyone who can open a PR that gets a preview deploy can ship code that runs against real accounts. Today previews only build for branches in this repository, because fork PRs do not get the Cloudflare secret. diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index 04e3bf4..82e1502 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -491,6 +491,19 @@ describe('do the items match the total', () => { expect(cash.totalCents).toBe(408) }) + it('never takes the cash handed over for the bill when no line says total', () => { + const tendered = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00', { + currency: 'USD', + }) + expect(tendered.totalCents).not.toBe(2000) + expect(tendered.itemsMatch).not.toBe('exact') + expect(tendered.itemsMatch).not.toBe('close') + // A card line for exactly what the items add up to is the bill. + const card = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nVISA 19.00', { currency: 'USD' }) + expect(card.totalCents).toBe(1900) + expect(card.itemsMatch).toBe('exact') + }) + it('drops a line dearer than the whole bill', () => { const scan = parseReceipt('Shop\nGarlic 81.89\nRice 2.99\nTotal 76.50', { currency: 'USD' }) expect(scan.items.map((i) => i.description)).toEqual(['Rice']) diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index f5d1e2a..592ead4 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -428,7 +428,8 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null continue } - largest = Math.max(largest ?? 0, price.cents) + // Cash handed over is not the bill: a payment line can only confirm a total (below). + if (kind !== 'payment') largest = Math.max(largest ?? 0, price.cents) switch (kind) { case 'subtotal': subtotal ??= price.cents diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index a3089a1..0186c61 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -418,6 +418,9 @@ export const expenses = { photo: 'Foto {number}', pdf: 'PDF {number}', preparing: 'Foto wird vorbereitet', + loading: 'Dateien dieser Ausgabe werden geladen', + loadFailed: + 'Die Dateien dieser Ausgabe wurden nicht geladen, daher lassen sie sich noch nicht ändern.', open: '{name} öffnen', alt: 'Foto {number} zu {description}', position: '{number} von {count}', diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index c2c336d..4d42c16 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -419,6 +419,8 @@ export const expenses = { photo: 'Photo {number}', pdf: 'PDF {number}', preparing: 'Getting the photo ready', + loading: 'Getting the files on this expense', + loadFailed: 'The files on this expense did not load, so they cannot be changed yet.', open: 'Open {name}', alt: 'Photo {number} for {description}', position: '{number} of {count}', diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index 1d6ce27..ebe8a7d 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -411,6 +411,8 @@ export const expenses = { photo: 'Foto {number}', pdf: 'PDF {number}', preparing: 'Preparando la foto', + loading: 'Cargando los archivos de este gasto', + loadFailed: 'Los archivos de este gasto no se cargaron, así que aún no se pueden cambiar.', open: 'Abrir {name}', alt: 'Foto {number} de {description}', position: '{number} de {count}', diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index f6e4bf1..4c1cf1c 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -418,6 +418,9 @@ export const expenses = { photo: 'Photo {number}', pdf: 'PDF {number}', preparing: 'Préparation de la photo', + loading: 'Chargement des fichiers de cette dépense', + loadFailed: + "Les fichiers de cette dépense n'ont pas pu être chargés, ils ne peuvent donc pas encore être modifiés.", open: 'Ouvrir {name}', alt: 'Photo {number} pour {description}', position: '{number} sur {count}', diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index aebc995..bb3504c 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -411,6 +411,8 @@ export const expenses = { photo: 'Foto {number}', pdf: 'PDF {number}', preparing: 'Preparando a foto', + loading: 'Carregando os arquivos desta despesa', + loadFailed: 'Os arquivos desta despesa não carregaram, então ainda não podem ser alterados.', open: 'Abrir {name}', alt: 'Foto {number} de {description}', position: '{number} de {count}', diff --git a/supabase/migrations/20261114015104_expense_attachments.sql b/supabase/migrations/20261114015104_expense_attachments.sql index 0283bc1..3cff0c7 100644 --- a/supabase/migrations/20261114015104_expense_attachments.sql +++ b/supabase/migrations/20261114015104_expense_attachments.sql @@ -183,8 +183,8 @@ where e.receipt_path is not null -- The RPCs --------------------------------------------------------------------------------------- --- Sets an expense's attachments, in order: `attachments` is a list of storage paths (or --- {path} objects) of files already uploaded under the group's folder. Up to five. Who may edit +-- Sets an expense's attachments, in order: `attachments` is a list of storage paths of files +-- already uploaded under the group's folder. Up to five. Who may edit -- the expense may change them (a member their own expenses, admins any); they are details, so -- an expense that names someone who left still takes them. A changed list is logged as one -- `expense.updated` with an `attachments` change (count before and after) when `log_edit`; the @@ -233,7 +233,6 @@ begin for entry in select x.v from jsonb_array_elements(attachments) as x (v) loop file_path := case jsonb_typeof(entry) when 'string' then entry #>> '{}' - when 'object' then entry ->> 'path' end; if file_path is null or file_path not like gid::text || '/%' or file_path like '%..%' or char_length(file_path) > 300 diff --git a/supabase/schema/current.sql b/supabase/schema/current.sql index b74df94..bd313b8 100644 --- a/supabase/schema/current.sql +++ b/supabase/schema/current.sql @@ -182,7 +182,6 @@ begin for entry in select x.v from jsonb_array_elements(attachments) as x (v) loop file_path := case jsonb_typeof(entry) when 'string' then entry #>> '{}' - when 'object' then entry ->> 'path' end; if file_path is null or file_path not like gid::text || '/%' or file_path like '%..%' or char_length(file_path) > 300 diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql index b7fab1b..8f18871 100644 --- a/supabase/tests/29_expense_attachments.test.sql +++ b/supabase/tests/29_expense_attachments.test.sql @@ -1,9 +1,9 @@ --- Several attachments per expense (20261113155139_expense_attachments, ADR 21): the files stay in +-- Several attachments per expense (20261114015104_expense_attachments, ADR 21): the files stay in -- the private receipts bucket under the group folder, so other groups, anon and an unfinished -- two-step sign in read none of them; set_expense_attachments and the receipt_path mirror for -- older clients. begin; -select plan(58); +select plan(59); select tests.create_user('ava'); select tests.create_user('ben'); @@ -106,13 +106,18 @@ select is( 'the same list changes nothing and logs nothing' ); --- Paths are plain strings or {path} objects. +-- Paths are plain strings. select tests.login('ava'); +select throws_ok( + format($$ select public.set_expense_attachments(%L, %L) $$, tests.id('e'), + jsonb_build_array(jsonb_build_object('path', pg_temp.file('one.jpg')))), + '22023', 'invalid_receipt_path', 'a path, not an object holding one' +); select is( - (select count(*)::int from public.set_expense_attachments(tests.id('e'), jsonb_build_array( - jsonb_build_object('path', pg_temp.file('one.jpg')), pg_temp.file('four.webp')))), + (select count(*)::int from public.set_expense_attachments(tests.id('e'), + pg_temp.files('one.jpg', 'four.webp'))), 2, - 'objects with a path work too' + 'a shorter list of paths' ); reset role; select is(pg_temp.paths('e'), 'one.jpg,four.webp', 'files left out are dropped from the list'); From 273e006433a3c2a42f243f4649ac79a0ce88c702 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:51:19 -0500 Subject: [PATCH 21/29] items make the total when no total line is read --- .../expenses/receipts/scan-fill.test.ts | 18 +++++++++++++++++- .../0021-receipt-scanning-and-attachments.md | 4 ++-- docs/sequences.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 18 +++++++++++++----- packages/core/src/receipts/parse-receipt.ts | 7 +++---- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index b9d9674..e9e4ee1 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -1,4 +1,4 @@ -import type { ReceiptScan } from '@potluck/core' +import { parseReceipt, type ReceiptScan } from '@potluck/core' import { describe, expect, it } from 'vitest' import { initialDraft, itemsTotal } from '../form/split-state' import { @@ -85,6 +85,22 @@ describe('fillFromScan', () => { expect(fill.amount).toBe(5617) }) + it('fills the bill the items make when the receipt has no total line', () => { + // 14.00 + 5.00 is 19.00; 20.00 was handed over and 1.00 came back. + const lines = ['Diner', 'Burger 14.00', 'Fries 5.00', 'CASH 20.00', 'CHANGE 1.00'] + const read = parseReceipt(lines.join('\n'), { + currency: 'USD', + }) + const choice = defaultChoice(read, '') + expect([choice.total, choice.items]).toEqual([true, true]) + const fill = fillFromScan(read, choice, { draft, currency: 'USD', memberIds: people }) + expect(fill.amount).toBe(1900) + expect(fill.draft?.items.map((i) => [i.name, i.amountCents])).toEqual([ + ['Burger', 1400], + ['Fries', 500], + ]) + }) + it('adds a line for whatever the receipt total has beyond its items', () => { const fill = fillFromScan(scan({ tipCents: null }), all, { draft, diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index 4097703..2c4f101 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -10,8 +10,8 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision - **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. -- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. -- **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before reading and uploading, which also drops their metadata. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the items make the total; a card or cash line only confirms a total equal to the items, and with no items either the total is left for the person to fill in. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. diff --git a/docs/sequences.md b/docs/sequences.md index 7426fa0..f399c58 100644 --- a/docs/sequences.md +++ b/docs/sequences.md @@ -334,7 +334,7 @@ The service worker shows the message. The PWA is built with `vite-plugin-pwa` in ## Scan a receipt and attach files -Scanning happens on the device and only fills the form; the expense is saved by the same `create_expense_v2` call as above ([ADR 21](adr/0021-receipt-scanning-and-attachments.md)). +Scanning happens on the device and only fills the form; the expense is saved by `create_expense_with_attachments` when it has files, otherwise by the same `create_expense_v2` call as above ([ADR 21](adr/0021-receipt-scanning-and-attachments.md)). ```mermaid sequenceDiagram diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index 82e1502..daa708c 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -220,7 +220,7 @@ describe('parseReceipt', () => { ]) }) - it('falls back to the largest amount when no line says total', () => { + it('adds up the items when no line says total', () => { const scan = parseReceipt('Corner Shop\nMilk 1.20\nBread 2.10\n3.30', { currency: 'USD' }) expect(scan.totalCents).toBe(330) expect(items(scan.items)).toEqual([ @@ -491,19 +491,27 @@ describe('do the items match the total', () => { expect(cash.totalCents).toBe(408) }) - it('never takes the cash handed over for the bill when no line says total', () => { + it('takes neither the cash handed over nor the dearest item for the bill when no line says total', () => { + // 14.00 + 5.00 is 19.00; 20.00 was handed over and 1.00 came back. const tendered = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00', { currency: 'USD', }) - expect(tendered.totalCents).not.toBe(2000) - expect(tendered.itemsMatch).not.toBe('exact') - expect(tendered.itemsMatch).not.toBe('close') + expect(tendered.totalCents).toBe(1900) + expect(tendered.itemsMatch).toBe('exact') + expect(items(tendered.items)).toEqual([ + ['Burger', 1400, 1], + ['Fries', 500, 1], + ]) // A card line for exactly what the items add up to is the bill. const card = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nVISA 19.00', { currency: 'USD' }) expect(card.totalCents).toBe(1900) expect(card.itemsMatch).toBe('exact') }) + it('leaves the total empty when no line says total and no items were read', () => { + expect(parseReceipt('Shop\nCash 10.00\nChange 1.00', { currency: 'USD' }).totalCents).toBeNull() + }) + it('drops a line dearer than the whole bill', () => { const scan = parseReceipt('Shop\nGarlic 81.89\nRice 2.99\nTotal 76.50', { currency: 'USD' }) expect(scan.items.map((i) => i.description)).toEqual(['Rice']) diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 592ead4..b30f5f2 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -405,7 +405,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip let tax: number | null = null let tip: number | null = null let discount: number | null = null - let largest: number | null = null let itemsEnd = lines.length const items: ReceiptItem[] = [] let pending: string | null = null @@ -428,8 +427,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null continue } - // Cash handed over is not the bill: a payment line can only confirm a total (below). - if (kind !== 'payment') largest = Math.max(largest ?? 0, price.cents) switch (kind) { case 'subtotal': subtotal ??= price.cents @@ -492,7 +489,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null } - if (total === null && largest !== null) total = largest // A line dearer than the whole bill is a misread (a smudge read as 81.89). const offered = items .filter((i) => total === null || i.amountCents <= total) @@ -516,6 +512,9 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // card line was the bill ("TOTAL $76.59" over "VISA $76.50" on a blurred photo). const formSum = itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) if (offered.length > 0 && total !== formSum && paid.includes(formSum)) total = formSum + // No total line read: the items make the bill. Neither a single line nor the cash handed over + // is the total, and with no items either the review asks for it. + if (total === null && offered.length > 0) total = formSum return { merchant: findMerchant(lines), From ff2335c322affd0e5bf14907ee1542fb0b5bc6be Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:00:55 -0500 Subject: [PATCH 22/29] derive missing receipt totals and mark them unconfirmed --- .../expenses/receipts/receipts.test.tsx | 8 +++ .../expenses/receipts/scan-fill.test.ts | 26 +++++++++ .../features/expenses/receipts/scan-fill.ts | 17 ++++-- .../expenses/receipts/scan-receipt.tsx | 43 ++++++++------ .../0021-receipt-scanning-and-attachments.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 56 +++++++++++++++++-- packages/core/src/receipts/parse-receipt.ts | 51 ++++++++++++++--- packages/i18n/src/de/money.ts | 2 + packages/i18n/src/en/money.ts | 2 + packages/i18n/src/es/money.ts | 2 + packages/i18n/src/fr/money.ts | 2 + packages/i18n/src/pt/money.ts | 2 + 12 files changed, 178 insertions(+), 35 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/receipts.test.tsx b/apps/web/src/features/expenses/receipts/receipts.test.tsx index 0d91632..a9e1116 100644 --- a/apps/web/src/features/expenses/receipts/receipts.test.tsx +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -124,6 +124,14 @@ describe('ScanReceipt', () => { ).toBeInTheDocument() }) + it('says when no total line was read and the total was worked out', async () => { + ocr.text = 'Cafe\nTea 3.00\nCake 4.00' + const { pick } = renderScan() + await pick() + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + expect(within(sheet).getByText(t('expenses.scan.totalDerived'))).toBeInTheDocument() + }) + it('offers to attach the photo when nothing reads as prices', async () => { ocr.text = 'a blurry thumb' const { onApply, pick } = renderScan() diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index e9e4ee1..4c911b5 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -5,6 +5,7 @@ import { bestScan, defaultChoice, fillFromScan, + readingsEnough, scanFoundSomething, scanItemsTotal, } from './scan-fill' @@ -22,6 +23,7 @@ const scan = (over: Partial = {}): ReceiptScan => ({ { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, { description: 'Fries', amountCents: 1950, quantity: 1 }, ], + totalDerived: false, itemsMatch: 'exact', ...over, }) @@ -52,6 +54,30 @@ describe('scan review defaults', () => { expect(bestScan([empty])).toBe(empty) }) + it('ranks a total worked out without a total line below one the receipt printed', () => { + const usd = { currency: 'USD' } + // The first reading caught only the burger and no total line; the second read TOTAL 19.00 + // with the fries misread as 4.90. + const partial = parseReceipt(['Diner', 'Burger 14.00'].join('\n'), usd) + const printed = parseReceipt( + ['Diner', 'Burger 14.00', 'Fries 4.90', 'TOTAL 19.00'].join('\n'), + usd, + ) + expect(printed.itemsMatch).toBe('close') + expect(bestScan([partial, printed])).toBe(printed) + expect(bestScan([printed, partial])).toBe(printed) + }) + + it('keeps reading while the only total was worked out', () => { + const usd = { currency: 'USD' } + const partial = parseReceipt(['Diner', 'Burger 14.00'].join('\n'), usd) + const derived = parseReceipt(['Diner', 'Burger 14.00', 'Fries 5.00'].join('\n'), usd) + const printed = parseReceipt(['Diner', 'Burger 14.00', 'TOTAL 14.00'].join('\n'), usd) + expect(readingsEnough([partial])).toBe(false) + expect(readingsEnough([partial, derived])).toBe(false) + expect(readingsEnough([printed])).toBe(true) + }) + it('knows when a scan found nothing usable', () => { expect(scanFoundSomething(scan())).toBe(true) expect(scanFoundSomething(scan({ items: [] }))).toBe(true) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts index 1438016..a397b43 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -30,20 +30,29 @@ export function defaultChoice(scan: ReceiptScan, description: string): ScanChoic } } -const RANK = { exact: 3, close: 2, off: 1 } as const +const RANK = { exact: 3, close: 2, off: 1, unconfirmed: 0 } as const /** - * The best of several readings of one photo: the one whose items come closest to its total, - * then the one that found the most, then the first. + * The best of several readings of one photo: the one whose items come closest to the total it + * read, then one that read a total line at all, then the one that found the most, then the first. */ export function bestScan(scans: ReceiptScan[]): ReceiptScan { const score = (s: ReceiptScan) => (s.itemsMatch ? RANK[s.itemsMatch] : 0) * 1000 + - (s.totalCents !== null ? 100 : 0) + + (s.totalCents !== null && !s.totalDerived ? 100 : 0) + s.items.length return scans.reduce((best, s) => (score(s) > score(best) ? s : best)) } +/** + * Whether the reader can stop: one reading whose items add up to a total line it read is + * enough; after two, anything close is. A total worked out without a total line never is. + */ +export function readingsEnough(scans: ReceiptScan[]): boolean { + const best = bestScan(scans).itemsMatch + return best === 'exact' || (scans.length > 1 && best === 'close') +} + /** Whether the scan found anything the form could use. */ export function scanFoundSomething(scan: ReceiptScan): boolean { return scan.totalCents !== null || scan.items.length > 0 diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index 6e658f2..5754a9e 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -24,6 +24,7 @@ import { bestScan, defaultChoice, fillFromScan, + readingsEnough, scanFoundSomething, scanItemsTotal, type ScanChoice, @@ -102,12 +103,8 @@ export function ScanReceipt({ signal: controller.signal, onProgress: ({ stage, progress }) => setState((s) => (s.phase === 'reading' ? { ...s, stage, progress } : s)), - // One reading that adds up is enough; after two, anything close is. Only a reading that - // is still far off tries the plain colour photo too. - enough: (texts) => { - const best = bestScan(texts.map(parse)).itemsMatch - return best === 'exact' || (texts.length > 1 && best === 'close') - }, + // Only a reading that is still far off tries the plain colour photo too. + enough: (texts) => readingsEnough(texts.map(parse)), }) const scan = bestScan((texts.length > 0 ? texts : ['']).map(parse)) const photo = await upload @@ -480,7 +477,7 @@ function Review({ price( t('expenses.scan.total'), scan.totalCents, - (cents) => edit({ totalCents: cents }), + (cents) => edit({ totalCents: cents, totalDerived: false }), 'text-lg font-extrabold', ), true, @@ -494,23 +491,35 @@ function Review({

    )}
  • - {scan.itemsMatch !== null && scan.itemsMatch !== 'exact' && scan.totalCents !== null && ( + {scan.totalDerived && scan.totalCents !== null && (
    -

    - - {t('expenses.scan.itemsOff', { - items: formatMoney(itemsTotal, currency), - total: formatMoney(scan.totalCents, currency), - })} - {' '} - {t('expenses.scan.checkPrices')} -

    +

    {t('expenses.scan.totalDerived')}

    )} + {scan.itemsMatch !== null && + scan.itemsMatch !== 'exact' && + scan.itemsMatch !== 'unconfirmed' && + scan.totalCents !== null && ( +
    + +

    + + {t('expenses.scan.itemsOff', { + items: formatMoney(itemsTotal, currency), + total: formatMoney(scan.totalCents, currency), + })} + {' '} + {t('expenses.scan.checkPrices')} +

    +
    + )} {canAttach && ( /` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. -- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the items make the total; a card or cash line only confirms a total equal to the items, and with no items either the total is left for the person to fill in. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the total is worked out, in order, from the card line, the cash handed over less the change, the subtotal with tax and tip on top, or the items, and the same reconciliation then decides what tax, tip and discount to keep (included VAT is never added). Such a total is marked `totalDerived`: it never counts as the items matching, so the reader still looks again, a reading with a printed total ranks above it, and the review asks the person to check it. Cash handed over alone or a single item is never the total. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. - **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index daa708c..c0e159e 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -120,6 +120,7 @@ describe('parseReceipt', () => { taxCents: 367, tipCents: 800, discountCents: null, + totalDerived: false, itemsMatch: 'exact', items: [ { description: 'Cheeseburger', amountCents: 2500, quantity: 2 }, @@ -289,6 +290,7 @@ describe('parseReceipt', () => { tipCents: null, discountCents: null, items: [], + totalDerived: false, itemsMatch: null, }) const scan = parseReceipt('12\n****\nhello there', { currency: 'USD' }) @@ -497,19 +499,63 @@ describe('do the items match the total', () => { currency: 'USD', }) expect(tendered.totalCents).toBe(1900) - expect(tendered.itemsMatch).toBe('exact') + expect(tendered.totalDerived).toBe(true) expect(items(tendered.items)).toEqual([ ['Burger', 1400, 1], ['Fries', 500, 1], ]) - // A card line for exactly what the items add up to is the bill. + // What the card paid is the bill. const card = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nVISA 19.00', { currency: 'USD' }) expect(card.totalCents).toBe(1900) - expect(card.itemsMatch).toBe('exact') + // Cash handed over with no change line says nothing about the bill. + const cashOnly = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nCASH 20.00', { + currency: 'USD', + }) + expect(cashOnly.totalCents).toBe(1900) + }) + + it('never calls a total it worked out a match, so the reader looks again', () => { + // A first reading that caught only the burger: 14.00 is not the bill, and nothing says so. + const partial = parseReceipt('Diner\nBurger 14.00', { currency: 'USD' }) + expect(partial.totalCents).toBe(1400) + expect(partial.totalDerived).toBe(true) + expect(partial.itemsMatch).toBe('unconfirmed') + const read = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nTOTAL 19.00', { currency: 'USD' }) + expect([read.totalDerived, read.itemsMatch]).toEqual([false, 'exact']) + }) + + it.each([ + // The receipts' own TOTAL lines (31.85, 30.30, 82.00) are what the card paid. + ['a UK pub, VAT included', UK_PUB, 3185, null], + ['a French café, dont TVA', FR_CAFE, 3030, null], + ['a Spanish bill, IVA incluido and a tip', ES_RESTAURANT, 8200, 500], + ])( + 'takes the card line for the bill when the total line is lost: %s', + (_, receipt, total, tip) => { + const lines = receipt.split('\n') + const lost = lines.filter((line) => !/^total/i.test(line)) + expect(lost).toHaveLength(lines.length - 1) + const scan = parseReceipt(lost.join('\n'), { currency: 'EUR' }) + expect(scan.totalCents).toBe(total) + expect(scan.taxCents).toBeNull() + expect(scan.tipCents).toBe(tip) + expect(scan.totalDerived).toBe(true) + }, + ) + + it('adds tax on top of a subtotal when the total line is lost', () => { + // Burger 14.00 and Fries 5.00 make 19.00, with 1.52 tax: 20.52. The fries were not read. + const scan = parseReceipt('Diner\nBurger 14.00\nSubtotal 19.00\nTax 1.52', { + currency: 'USD', + }) + expect(scan.totalCents).toBe(2052) + expect(scan.taxCents).toBe(152) + expect(scan.itemsMatch).toBe('unconfirmed') }) - it('leaves the total empty when no line says total and no items were read', () => { - expect(parseReceipt('Shop\nCash 10.00\nChange 1.00', { currency: 'USD' }).totalCents).toBeNull() + it('works the bill out from cash less change, and leaves it empty with nothing to go on', () => { + expect(parseReceipt('Shop\nCash 10.00\nChange 1.00', { currency: 'USD' }).totalCents).toBe(900) + expect(parseReceipt('Shop\nCash 10.00', { currency: 'USD' }).totalCents).toBeNull() }) it('drops a line dearer than the whole bill', () => { diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index b30f5f2..b1fb66d 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -25,12 +25,18 @@ export interface ReceiptScan { tipCents: number | null discountCents: number | null items: ReceiptItem[] + /** + * True when no total line was read and the total was worked out from the card line, the cash + * and change lines, the subtotal or the items. The person should check it. + */ + totalDerived: boolean /** * Whether the items (with tax, tip and discount) come to the total: `exact`, `close` (within - * 5%), `off`, or null when there is no total or no item to compare. Anything but `exact` means - * a line was misread or missed, and the review says so. + * 5%), `off`, `unconfirmed` when the total was worked out rather than read (so it cannot + * confirm the items), or null when there is no total or no item to compare. Anything but + * `exact` means a line was misread or missed, and the review says so. */ - itemsMatch: 'exact' | 'close' | 'off' | null + itemsMatch: 'exact' | 'close' | 'off' | 'unconfirmed' | null } export interface ParseReceiptOptions { @@ -170,7 +176,7 @@ function trailingPrice(line: string, currency: string): Priced | null { const SUBTOTAL = /\b(sub\s*-?\s*total|subtot|sous\s*-?\s*total|zwischensumme|zw\.?\s*summe|total\s+ht|net\s+total|total\s+parcial|base\s+imponible|netto(betrag)?)\b/ -const INCLUSIVE = /\b(incl|inkl|incluse?|incluido|included|ttc|inc)\b/ +const INCLUSIVE = /\b(incl|inkl|incluse?|incluido|included|ttc|inc|dont)\b/ const TAX = /\b(tax|taxes|vat|tva|iva|mwst|mw\.?\s?st|ust|gst|hst|pst|qst|impuesto|imposto|steuer|tasse)\b/ const TIP = @@ -181,6 +187,13 @@ const TOTAL = /\b(total|totale|totaal|gesamt\w*|summe|betrag|amount\s+due|balance\s+due|to\s+pay|a\s+pagar|zu\s+zahlen|montant|net\s+a\s+payer)\b/ const PAYMENT = /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b|\bbar\s+(eur|chf)\b/ +// Which payment line it is: the change given back, a card, or cash handed over. +const CHANGE_GIVEN = /\b(change|rendu|rendue|monnaie|wechselgeld|ruckgeld|cambio|troco)\b/ +const CARD_PAID = + /\b(card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|paypal|carte|cb|karte|kartenzahlung|kontaktlos|girocard|tarjeta|debito|credito|cartao|pix)\b|^ec\b/ +const CASH_PAID = + /\b(cash|tendered|tend|especes|gegeben|geg|efectivo|dinheiro)\b|^bar\b|\bbar\s+(eur|chf)\b/ + const NOT_AN_ITEM = /\b(tel|phone|fax|www|http|order|table|server|guest|guests|cashier|kasse|receipt|invoice|facture|rechnung|ticket|recibo|qty|items|articles|artikel|anzahl)\b/ @@ -370,10 +383,14 @@ function itemOf(rest: string): { description: string; quantity: number } { * after the person corrects a price. */ export function receiptItemsMatch( - scan: Pick, + scan: Pick< + ReceiptScan, + 'totalCents' | 'totalDerived' | 'taxCents' | 'tipCents' | 'discountCents' | 'items' + >, ): ReceiptScan['itemsMatch'] { const total = scan.totalCents if (total === null || total <= 0 || scan.items.length === 0) return null + if (scan.totalDerived) return 'unconfirmed' const sum = scan.items.reduce((a, i) => a + i.amountCents, 0) + (scan.taxCents ?? 0) + @@ -413,6 +430,11 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip let pendingQty: { count: number | null; unit: number } | null = null // What the card or cash lines say was paid. const paid: number[] = [] + const card: number[] = [] + const cash: number[] = [] + const change: number[] = [] + // Tax printed on top of the prices, not VAT they already include ("dont TVA", "IVA incluido"). + let addedTax = 0 for (const [index, line] of lines.entries()) { const key = keys[index]! @@ -439,6 +461,7 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip break case 'tax': tax = (tax ?? 0) + price.cents + if (!INCLUSIVE.test(key)) addedTax += price.cents break case 'tip': tip = (tip ?? 0) + price.cents @@ -448,6 +471,9 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip break case 'payment': paid.push(price.cents) + if (CHANGE_GIVEN.test(key)) change.push(price.cents) + else if (CARD_PAID.test(key)) card.push(price.cents) + else if (CASH_PAID.test(key)) cash.push(price.cents) itemsEnd = Math.min(itemsEnd, index) break case 'item': { @@ -489,11 +515,21 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null } + // No total line read: what the card paid, the cash handed over less the change, the subtotal + // with tax and tip on top, or else the items. Never one item or the cash handed over alone. + const totalDerived = total === null + if (total === null) { + const handedOver = cash[0] !== undefined && change[0] !== undefined ? cash[0] - change[0] : 0 + if (card[0] !== undefined) total = card[0] + else if (handedOver > 0) total = handedOver + else if (subtotal !== null) total = subtotal + addedTax + (tip ?? 0) + } // A line dearer than the whole bill is a misread (a smudge read as 81.89). const offered = items .filter((i) => total === null || i.amountCents <= total) .slice(0, MAX_RECEIPT_ITEMS) const itemsSum = offered.reduce((a, i) => a + i.amountCents, 0) + if (total === null && offered.length > 0) total = itemsSum const base = subtotal ?? itemsSum // Tax, tip and discount are kept only when they make the receipt add up the way the expense // form adds it (items + tax + tip - discount). VAT already inside the prices is left out. @@ -512,9 +548,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // card line was the bill ("TOTAL $76.59" over "VISA $76.50" on a blurred photo). const formSum = itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) if (offered.length > 0 && total !== formSum && paid.includes(formSum)) total = formSum - // No total line read: the items make the bill. Neither a single line nor the cash handed over - // is the total, and with no items either the review asks for it. - if (total === null && offered.length > 0) total = formSum return { merchant: findMerchant(lines), @@ -532,8 +565,10 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip tipCents: tip, discountCents: discount, items: offered, + totalDerived: totalDerived && total !== null, itemsMatch: receiptItemsMatch({ totalCents: total, + totalDerived, taxCents: tax, tipCents: tip, discountCents: discount, diff --git a/packages/i18n/src/de/money.ts b/packages/i18n/src/de/money.ts index 0186c61..5804c06 100644 --- a/packages/i18n/src/de/money.ts +++ b/packages/i18n/src/de/money.ts @@ -458,6 +458,8 @@ export const expenses = { items_other: '{count} Posten', itemsHint: 'Nach Posten geteilt, am Anfang mit allen auf jeder Zeile.', itemsOff: 'Die Posten ergeben {items}, der Beleg sagt {total}.', + totalDerived: + 'Keine Summenzeile gefunden, die Summe wurde aus dem Beleg berechnet. Prüfe sie am Foto.', rest: 'Alles andere', quantity: '{count}× {name}', attach: 'Foto anhängen', diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts index 4d42c16..05dec14 100644 --- a/packages/i18n/src/en/money.ts +++ b/packages/i18n/src/en/money.ts @@ -456,6 +456,8 @@ export const expenses = { items_other: '{count} items', itemsHint: 'Splits by item, with everyone on every line to start.', itemsOff: 'The items come to {items}; the receipt says {total}.', + totalDerived: + 'No total line was found, so the total was worked out from the receipt. Check it against the photo.', rest: 'Everything else', quantity: '{count}× {name}', attach: 'Attach the photo', diff --git a/packages/i18n/src/es/money.ts b/packages/i18n/src/es/money.ts index ebe8a7d..4d3a63b 100644 --- a/packages/i18n/src/es/money.ts +++ b/packages/i18n/src/es/money.ts @@ -450,6 +450,8 @@ export const expenses = { items_other: '{count} artículos', itemsHint: 'Se reparte por artículo, con todos en cada línea para empezar.', itemsOff: 'Los artículos suman {items}; el recibo dice {total}.', + totalDerived: + 'No se encontró la línea del total, así que se calculó a partir del recibo. Compruébalo con la foto.', rest: 'Todo lo demás', quantity: '{count}× {name}', attach: 'Adjuntar la foto', diff --git a/packages/i18n/src/fr/money.ts b/packages/i18n/src/fr/money.ts index 4c1cf1c..97cd196 100644 --- a/packages/i18n/src/fr/money.ts +++ b/packages/i18n/src/fr/money.ts @@ -459,6 +459,8 @@ export const expenses = { items_other: '{count} articles', itemsHint: 'Partagé par article, avec tout le monde sur chaque ligne pour commencer.', itemsOff: 'Les articles font {items} ; le reçu dit {total}.', + totalDerived: + 'Aucune ligne de total trouvée : le total a été calculé à partir du reçu. Vérifie-le sur la photo.', rest: 'Tout le reste', quantity: '{count}× {name}', attach: 'Joindre la photo', diff --git a/packages/i18n/src/pt/money.ts b/packages/i18n/src/pt/money.ts index bb3504c..7bae0c5 100644 --- a/packages/i18n/src/pt/money.ts +++ b/packages/i18n/src/pt/money.ts @@ -449,6 +449,8 @@ export const expenses = { items_other: '{count} itens', itemsHint: 'Dividido por item, com todo mundo em cada linha para começar.', itemsOff: 'Os itens somam {items}; o recibo diz {total}.', + totalDerived: + 'Nenhuma linha de total foi encontrada, então o total foi calculado a partir do recibo. Confira com a foto.', rest: 'Todo o resto', quantity: '{count}× {name}', attach: 'Anexar a foto', From 5e244abc85d50bee1c1ab418818180152117cfe2 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:07:34 -0500 Subject: [PATCH 23/29] anchor payment lines and sum split tender for derived totals --- .../0021-receipt-scanning-and-attachments.md | 2 +- docs/receipts.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 40 +++++++++++++++++++ packages/core/src/receipts/parse-receipt.ts | 21 ++++++---- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index d7cb4a4..56760c0 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -10,7 +10,7 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision - **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. -- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the total is worked out, in order, from the card line, the cash handed over less the change, the subtotal with tax and tip on top, or the items, and the same reconciliation then decides what tax, tip and discount to keep (included VAT is never added). Such a total is marked `totalDerived`: it never counts as the items matching, so the reader still looks again, a reading with a printed total ranks above it, and the review asks the person to check it. Cash handed over alone or a single item is never the total. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the total is worked out, in order, from what was paid (every card line, since a bill can be split, plus the cash handed over less the change), the subtotal with tax and tip on top, or the items, and the same reconciliation then decides what tax, tip and discount to keep (included VAT is never added). Such a total is marked `totalDerived`: it never counts as the items matching, so the reader still looks again, a reading with a printed total ranks above it, and the review asks the person to check it. Cash handed over alone or a single item is never the total, and a worked-out total never drops an item as dearer than the bill. A payment line starts with its payment word, so "Birthday Card 4.99" stays an item. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. - **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. diff --git a/docs/receipts.md b/docs/receipts.md index 8529d60..cc9e055 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -20,7 +20,7 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The ## Rules - Only `ocr.ts` imports `tesseract.js`, and only with a dynamic `import()`, so it never lands in the app bundle or the precache. Its worker, core and model come from `/ocr//` on our origin. -- OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, a second pass reads an upscaled copy; when the best so far is still far off, a third reads the plain colour photo (which suits some long receipts better). The best reading wins (`bestScan`). +- OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, or there was no total line and the total had to be worked out (that never counts as enough), a second pass reads an upscaled copy; when the best so far is still far off, a third reads the plain colour photo (which suits some long receipts better). The best reading wins (`bestScan`). - The upload copy (`prepareImage`) is at most 2000 px, JPEG on white. Re-encoding drops the photo's metadata, location included. HEIC outside Safari and PDFs go up as they are. - A scan only fills the form. The review sheet shows the photo and lets each price be corrected; the person saves the expense. - The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off). diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index c0e159e..c43cc2d 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -558,6 +558,46 @@ describe('do the items match the total', () => { expect(parseReceipt('Shop\nCash 10.00', { currency: 'USD' }).totalCents).toBeNull() }) + it('keeps an item named after a payment, and takes the card line under it for the bill', () => { + // 6.49 + 4.99 + 3.29 is 14.77, what the VISA line says was paid. + const scan = parseReceipt( + 'CVS\nShampoo 6.49\nBirthday Card 4.99\nToothpaste 3.29\nVISA 14.77', + { + currency: 'USD', + }, + ) + expect(scan.totalCents).toBe(1477) + expect(items(scan.items)).toEqual([ + ['Shampoo', 649, 1], + ['Birthday Card', 499, 1], + ['Toothpaste', 329, 1], + ]) + const snacks = parseReceipt('Pub\nBar snacks 4.00\nCider 5.00\nTotal 9.00', { currency: 'GBP' }) + expect(snacks.items.map((i) => i.description)).toEqual(['Bar snacks', 'Cider']) + expect(snacks.itemsMatch).toBe('exact') + }) + + it('adds up a bill paid in parts when the total line is lost', () => { + // A 5.00 gift card and 14.00 on VISA paid for 12.00 + 7.00. + const scan = parseReceipt('Shop\nShirt 12.00\nSocks 7.00\nGIFT CARD 5.00\nVISA 14.00', { + currency: 'USD', + }) + expect(scan.totalCents).toBe(1900) + expect(items(scan.items)).toEqual([ + ['Shirt', 1200, 1], + ['Socks', 700, 1], + ]) + }) + + it('never drops an item as dearer than a total it worked out', () => { + // A subtotal misread as 25.00 and no total line: the 30.00 steak is still an item. + const scan = parseReceipt('Grill\nSteak 30.00\nSalad 8.00\nSubtotal 25.00', { + currency: 'USD', + }) + expect(scan.items.map((i) => i.description)).toEqual(['Steak', 'Salad']) + expect(scan.totalDerived).toBe(true) + }) + it('drops a line dearer than the whole bill', () => { const scan = parseReceipt('Shop\nGarlic 81.89\nRice 2.99\nTotal 76.50', { currency: 'USD' }) expect(scan.items.map((i) => i.description)).toEqual(['Rice']) diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index b1fb66d..14c9086 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -185,8 +185,10 @@ const DISCOUNT = /\b(discount|remise|rabatt|descuento|desconto|coupon|savings|you\s+saved|reduction|promo)\b/ const TOTAL = /\b(total|totale|totaal|gesamt\w*|summe|betrag|amount\s+due|balance\s+due|to\s+pay|a\s+pagar|zu\s+zahlen|montant|net\s+a\s+payer)\b/ +// A payment line starts with the payment word ("VISA 14.77", "Geg. BAR EUR 10,00"); one inside a +// name ("Birthday Card 4.99", "Bar snacks 4.00") is an item. const PAYMENT = - /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b|\bbar\s+(eur|chf)\b/ + /^[^a-z\d]*((cash|change|card|gift\s*card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty|ec)\b|bar\s*((eur|chf)\s*)?[\d-])/ // Which payment line it is: the change given back, a card, or cash handed over. const CHANGE_GIVEN = /\b(change|rendu|rendue|monnaie|wechselgeld|ruckgeld|cambio|troco)\b/ const CARD_PAID = @@ -515,18 +517,21 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null } - // No total line read: what the card paid, the cash handed over less the change, the subtotal - // with tax and tip on top, or else the items. Never one item or the cash handed over alone. + // No total line read: what was paid (every card line, as a bill can be split, and the cash + // handed over less the change), else the subtotal with tax and tip on top, else the items. + // Never one item or the cash handed over alone. const totalDerived = total === null if (total === null) { - const handedOver = cash[0] !== undefined && change[0] !== undefined ? cash[0] - change[0] : 0 - if (card[0] !== undefined) total = card[0] - else if (handedOver > 0) total = handedOver + const sum = (amounts: number[]) => amounts.reduce((a, c) => a + c, 0) + const handedOver = cash.length > 0 && change.length > 0 ? sum(cash) - sum(change) : 0 + const paidOut = sum(card) + Math.max(handedOver, 0) + if (paidOut > 0) total = paidOut else if (subtotal !== null) total = subtotal + addedTax + (tip ?? 0) } - // A line dearer than the whole bill is a misread (a smudge read as 81.89). + // A line dearer than a printed total is a misread (a smudge read as 81.89). A total worked out + // here cannot tell. const offered = items - .filter((i) => total === null || i.amountCents <= total) + .filter((i) => total === null || totalDerived || i.amountCents <= total) .slice(0, MAX_RECEIPT_ITEMS) const itemsSum = offered.reduce((a, i) => a + i.amountCents, 0) if (total === null && offered.length > 0) total = itemsSum From ef6207181415bd3cc3923fae38c6d460d8da42ca Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:15:41 -0500 Subject: [PATCH 24/29] payment lines only confirm a missing total, never supply it --- .../expenses/receipts/receipts.test.tsx | 16 ++ .../expenses/receipts/scan-fill.test.ts | 14 ++ .../features/expenses/receipts/scan-fill.ts | 2 +- .../0021-receipt-scanning-and-attachments.md | 2 +- docs/receipts.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 141 +++++++++++------- packages/core/src/receipts/parse-receipt.ts | 59 ++++---- 7 files changed, 148 insertions(+), 88 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/receipts.test.tsx b/apps/web/src/features/expenses/receipts/receipts.test.tsx index a9e1116..fd8a1f3 100644 --- a/apps/web/src/features/expenses/receipts/receipts.test.tsx +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -132,6 +132,22 @@ describe('ScanReceipt', () => { expect(within(sheet).getByText(t('expenses.scan.totalDerived'))).toBeInTheDocument() }) + it('says when the items are far off a subtotal taken for the total', async () => { + ocr.text = 'Cafe\nTea 3.00\nSubtotal 7.00' + const { pick } = renderScan() + await pick() + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + expect(within(sheet).getByText(t('expenses.scan.totalDerived'))).toBeInTheDocument() + expect( + within(sheet).getByText( + t('expenses.scan.itemsOff', { + items: formatMoney(300, 'USD'), + total: formatMoney(700, 'USD'), + }), + ), + ).toBeInTheDocument() + }) + it('offers to attach the photo when nothing reads as prices', async () => { ocr.text = 'a blurry thumb' const { onApply, pick } = renderScan() diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index 4c911b5..504fd19 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -78,6 +78,20 @@ describe('scan review defaults', () => { expect(readingsEnough([printed])).toBe(true) }) + it('leaves items unticked when they are far off a subtotal taken for the total', () => { + // Burger 14.00 and Fries 5.00 make the 19.00 subtotal; the fries were not read. + const read = parseReceipt(['Diner', 'Burger 14.00', 'Subtotal 19.00'].join('\n'), { + currency: 'USD', + }) + expect(defaultChoice(read, '')).toMatchObject({ total: true, items: false }) + expect(readingsEnough([read])).toBe(false) + // A reading with a printed total ranks above it even when its items are off too. + const printed = parseReceipt(['Diner', 'Burger 14.00', 'TOTAL 19.00'].join('\n'), { + currency: 'USD', + }) + expect(bestScan([read, printed])).toBe(printed) + }) + it('knows when a scan found nothing usable', () => { expect(scanFoundSomething(scan())).toBe(true) expect(scanFoundSomething(scan({ items: [] }))).toBe(true) diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts index a397b43..5994d72 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -38,7 +38,7 @@ const RANK = { exact: 3, close: 2, off: 1, unconfirmed: 0 } as const */ export function bestScan(scans: ReceiptScan[]): ReceiptScan { const score = (s: ReceiptScan) => - (s.itemsMatch ? RANK[s.itemsMatch] : 0) * 1000 + + (s.itemsMatch && !s.totalDerived ? RANK[s.itemsMatch] : 0) * 1000 + (s.totalCents !== null && !s.totalDerived ? 100 : 0) + s.items.length return scans.reduce((best, s) => (score(s) > score(best) ? s : best)) diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index 56760c0..daae46c 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -10,7 +10,7 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision - **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. -- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). With no total line, the total is worked out, in order, from what was paid (every card line, since a bill can be split, plus the cash handed over less the change), the subtotal with tax and tip on top, or the items, and the same reconciliation then decides what tax, tip and discount to keep (included VAT is never added). Such a total is marked `totalDerived`: it never counts as the items matching, so the reader still looks again, a reading with a printed total ranks above it, and the review asks the person to check it. Cash handed over alone or a single item is never the total, and a worked-out total never drops an item as dearer than the bill. A payment line starts with its payment word, so "Birthday Card 4.99" stays an item. It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). Payment lines never supply a total; they only confirm one. With no total line, the total is the subtotal, or else the items, with nothing added on top. A card or cash line for the same amount (or the cash handed over less the change) confirms it; otherwise it is marked `totalDerived`, never counts as enough for the reader, ranks below a reading with a printed total, never drops an item as dearer than the bill, and the review says the total was not on the receipt (and that the items are off when a subtotal disagrees with them). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. - **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. diff --git a/docs/receipts.md b/docs/receipts.md index cc9e055..255b7b9 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -23,7 +23,7 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The - OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, or there was no total line and the total had to be worked out (that never counts as enough), a second pass reads an upscaled copy; when the best so far is still far off, a third reads the plain colour photo (which suits some long receipts better). The best reading wins (`bestScan`). - The upload copy (`prepareImage`) is at most 2000 px, JPEG on white. Re-encoding drops the photo's metadata, location included. HEIC outside Safari and PDFs go up as they are. - A scan only fills the form. The review sheet shows the photo and lets each price be corrected; the person saves the expense. -- The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off). +- The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off, or unconfirmed). Payment lines never supply a total, they only confirm one: with no total line the total is the subtotal, or else the items, with nothing added, and it stays `totalDerived` (the review asks the person to check it) unless a card or cash line, or the cash handed over less the change, says the same amount. - Tests mock `./ocr`: jsdom has no workers or canvas. ## Attachments in the database diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index c43cc2d..96b1fb7 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -494,95 +494,134 @@ describe('do the items match the total', () => { }) it('takes neither the cash handed over nor the dearest item for the bill when no line says total', () => { - // 14.00 + 5.00 is 19.00; 20.00 was handed over and 1.00 came back. - const tendered = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nCASH 20.00\nCHANGE 1.00', { - currency: 'USD', - }) + // 14.00 + 5.00 is 19.00; 20.00 was handed over and 1.00 came back, so 19.00 was paid. + const tendered = parseReceipt( + ['Diner', 'Burger 14.00', 'Fries 5.00', 'CASH 20.00', 'CHANGE 1.00'].join('\n'), + { + currency: 'USD', + }, + ) expect(tendered.totalCents).toBe(1900) - expect(tendered.totalDerived).toBe(true) + expect([tendered.totalDerived, tendered.itemsMatch]).toEqual([false, 'exact']) expect(items(tendered.items)).toEqual([ ['Burger', 1400, 1], ['Fries', 500, 1], ]) - // What the card paid is the bill. - const card = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nVISA 19.00', { currency: 'USD' }) - expect(card.totalCents).toBe(1900) - // Cash handed over with no change line says nothing about the bill. - const cashOnly = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nCASH 20.00', { + // A card line for the same amount confirms it too. + const card = parseReceipt(['Diner', 'Burger 14.00', 'Fries 5.00', 'VISA 19.00'].join('\n'), { currency: 'USD', }) - expect(cashOnly.totalCents).toBe(1900) + expect([card.totalCents, card.totalDerived]).toEqual([1900, false]) + // Cash handed over with no change line confirms nothing. + const cashOnly = parseReceipt( + ['Diner', 'Burger 14.00', 'Fries 5.00', 'CASH 20.00'].join('\n'), + { + currency: 'USD', + }, + ) + expect([cashOnly.totalCents, cashOnly.totalDerived]).toEqual([1900, true]) }) it('never calls a total it worked out a match, so the reader looks again', () => { // A first reading that caught only the burger: 14.00 is not the bill, and nothing says so. - const partial = parseReceipt('Diner\nBurger 14.00', { currency: 'USD' }) + const partial = parseReceipt(['Diner', 'Burger 14.00'].join('\n'), { currency: 'USD' }) expect(partial.totalCents).toBe(1400) expect(partial.totalDerived).toBe(true) expect(partial.itemsMatch).toBe('unconfirmed') - const read = parseReceipt('Diner\nBurger 14.00\nFries 5.00\nTOTAL 19.00', { currency: 'USD' }) + const read = parseReceipt(['Diner', 'Burger 14.00', 'Fries 5.00', 'TOTAL 19.00'].join('\n'), { + currency: 'USD', + }) expect([read.totalDerived, read.itemsMatch]).toEqual([false, 'exact']) }) it.each([ - // The receipts' own TOTAL lines (31.85, 30.30, 82.00) are what the card paid. - ['a UK pub, VAT included', UK_PUB, 3185, null], - ['a French café, dont TVA', FR_CAFE, 3030, null], - ['a Spanish bill, IVA incluido and a tip', ES_RESTAURANT, 8200, 500], + // The receipts' own TOTAL lines: 31.85 and 30.30 are what the card paid, 82.00 includes a tip. + ['a UK pub, VAT included', UK_PUB, 3185, false], + ['a French café, dont TVA', FR_CAFE, 3030, false], + ['a Spanish bill, IVA incluido and a tip', ES_RESTAURANT, 7700, true], ])( - 'takes the card line for the bill when the total line is lost: %s', - (_, receipt, total, tip) => { + 'takes the subtotal or the items when the total line is lost, never a payment line: %s', + (_, receipt, total, derived) => { const lines = receipt.split('\n') const lost = lines.filter((line) => !/^total/i.test(line)) expect(lost).toHaveLength(lines.length - 1) const scan = parseReceipt(lost.join('\n'), { currency: 'EUR' }) expect(scan.totalCents).toBe(total) - expect(scan.taxCents).toBeNull() - expect(scan.tipCents).toBe(tip) - expect(scan.totalDerived).toBe(true) + expect([scan.taxCents, scan.tipCents]).toEqual([null, null]) + expect(scan.totalDerived).toBe(derived) }, ) - it('adds tax on top of a subtotal when the total line is lost', () => { - // Burger 14.00 and Fries 5.00 make 19.00, with 1.52 tax: 20.52. The fries were not read. - const scan = parseReceipt('Diner\nBurger 14.00\nSubtotal 19.00\nTax 1.52', { + it.each([ + ['Paiement CB 30,30', ['Cafe creme 3,30', 'Croissant 27,00'], 3030], + ['Pago con tarjeta 30,30', ['Cafe 3,30', 'Bocadillo 27,00'], 3030], + ['Amount Tendered 20.00\nChange 1.00', ['Burger 14.00', 'Fries 5.00'], 1900], + ])('reads "%s" as a payment that confirms the items', (payment, lines, total) => { + const scan = parseReceipt(['Shop', ...lines, payment].join('\n'), { currency: 'EUR' }) + expect(items(scan.items).map(([name]) => name)).toEqual( + lines.map((l) => l.replace(/ [\d,.]+$/, '')), + ) + expect([scan.totalCents, scan.totalDerived, scan.itemsMatch]).toEqual([total, false, 'exact']) + }) + + it('takes a card line a few cents under a printed total for the bill', () => { + // Cafe creme 3,30 and Croissant 27,00 make 30,30, what the card paid; TOTAL 30,50 was misread. + const scan = parseReceipt( + ['Cafe', 'Cafe creme 3,30', 'Croissant 27,00', 'Total 30,50', 'Paiement CB 30,30'].join('\n'), + { + currency: 'EUR', + }, + ) + expect([scan.totalCents, scan.itemsMatch]).toEqual([3030, 'exact']) + }) + + it('never adds tax to a subtotal it takes for the total, and says when the items are off it', () => { + // Burger 14.00 and Fries 5.00 make the 19.00 subtotal; the fries were not read. + const scan = parseReceipt(['Diner', 'Burger 14.00', 'Subtotal 19.00', 'Tax 1.52'].join('\n'), { currency: 'USD', }) - expect(scan.totalCents).toBe(2052) - expect(scan.taxCents).toBe(152) - expect(scan.itemsMatch).toBe('unconfirmed') + expect(scan.totalCents).toBe(1900) + expect(scan.taxCents).toBeNull() + expect([scan.totalDerived, scan.itemsMatch]).toEqual([true, 'off']) }) - it('works the bill out from cash less change, and leaves it empty with nothing to go on', () => { - expect(parseReceipt('Shop\nCash 10.00\nChange 1.00', { currency: 'USD' }).totalCents).toBe(900) - expect(parseReceipt('Shop\nCash 10.00', { currency: 'USD' }).totalCents).toBeNull() + it('leaves the total empty with only payment lines to go on', () => { + expect( + parseReceipt(['Shop', 'Cash 10.00', 'Change 1.00'].join('\n'), { currency: 'USD' }) + .totalCents, + ).toBeNull() + expect( + parseReceipt(['Shop', 'Cash 10.00'].join('\n'), { currency: 'USD' }).totalCents, + ).toBeNull() }) - it('keeps an item named after a payment, and takes the card line under it for the bill', () => { - // 6.49 + 4.99 + 3.29 is 14.77, what the VISA line says was paid. - const scan = parseReceipt( - 'CVS\nShampoo 6.49\nBirthday Card 4.99\nToothpaste 3.29\nVISA 14.77', + it('never takes a card line that does not match the items for the bill', () => { + // "Birthday Card" reads as a payment line, so only the shampoo is an item before it. + const cvs = parseReceipt( + ['CVS', 'Shampoo 6.49', 'Birthday Card 4.99', 'Toothpaste 3.29', 'VISA 14.77'].join('\n'), { currency: 'USD', }, ) - expect(scan.totalCents).toBe(1477) - expect(items(scan.items)).toEqual([ - ['Shampoo', 649, 1], - ['Birthday Card', 499, 1], - ['Toothpaste', 329, 1], - ]) - const snacks = parseReceipt('Pub\nBar snacks 4.00\nCider 5.00\nTotal 9.00', { currency: 'GBP' }) - expect(snacks.items.map((i) => i.description)).toEqual(['Bar snacks', 'Cider']) - expect(snacks.itemsMatch).toBe('exact') + expect(items(cvs.items)).toEqual([['Shampoo', 649, 1]]) + expect([cvs.totalCents, cvs.totalDerived, cvs.itemsMatch]).toEqual([649, true, 'unconfirmed']) }) - it('adds up a bill paid in parts when the total line is lost', () => { - // A 5.00 gift card and 14.00 on VISA paid for 12.00 + 7.00. - const scan = parseReceipt('Shop\nShirt 12.00\nSocks 7.00\nGIFT CARD 5.00\nVISA 14.00', { - currency: 'USD', - }) + it('never adds up a bill paid in parts or a card balance', () => { + // 12.00 + 7.00 is 19.00: 5.00 on a gift card that has 20.00 left, and 14.00 on VISA. + const scan = parseReceipt( + [ + 'Shop', + 'Shirt 12.00', + 'Socks 7.00', + 'GIFT CARD 5.00', + 'VISA 14.00', + 'GIFT CARD BALANCE 20.00', + ].join('\n'), + { currency: 'USD' }, + ) expect(scan.totalCents).toBe(1900) + expect(scan.totalDerived).toBe(true) expect(items(scan.items)).toEqual([ ['Shirt', 1200, 1], ['Socks', 700, 1], @@ -591,11 +630,11 @@ describe('do the items match the total', () => { it('never drops an item as dearer than a total it worked out', () => { // A subtotal misread as 25.00 and no total line: the 30.00 steak is still an item. - const scan = parseReceipt('Grill\nSteak 30.00\nSalad 8.00\nSubtotal 25.00', { + const scan = parseReceipt(['Grill', 'Steak 30.00', 'Salad 8.00', 'Subtotal 25.00'].join('\n'), { currency: 'USD', }) expect(scan.items.map((i) => i.description)).toEqual(['Steak', 'Salad']) - expect(scan.totalDerived).toBe(true) + expect([scan.totalDerived, scan.itemsMatch]).toEqual([true, 'off']) }) it('drops a line dearer than the whole bill', () => { diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 14c9086..6fef70a 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -26,14 +26,14 @@ export interface ReceiptScan { discountCents: number | null items: ReceiptItem[] /** - * True when no total line was read and the total was worked out from the card line, the cash - * and change lines, the subtotal or the items. The person should check it. + * True when no total line was read and the total is the subtotal, or else the items, with no + * payment line saying the same amount. The person should check it. */ totalDerived: boolean /** * Whether the items (with tax, tip and discount) come to the total: `exact`, `close` (within - * 5%), `off`, `unconfirmed` when the total was worked out rather than read (so it cannot - * confirm the items), or null when there is no total or no item to compare. Anything but + * 5%), `off`, `unconfirmed` when the total was worked out rather than read and the items are + * not far off it (it cannot confirm them), or null when there is no total or no item to compare. Anything but * `exact` means a line was misread or missed, and the review says so. */ itemsMatch: 'exact' | 'close' | 'off' | 'unconfirmed' | null @@ -176,7 +176,7 @@ function trailingPrice(line: string, currency: string): Priced | null { const SUBTOTAL = /\b(sub\s*-?\s*total|subtot|sous\s*-?\s*total|zwischensumme|zw\.?\s*summe|total\s+ht|net\s+total|total\s+parcial|base\s+imponible|netto(betrag)?)\b/ -const INCLUSIVE = /\b(incl|inkl|incluse?|incluido|included|ttc|inc|dont)\b/ +const INCLUSIVE = /\b(incl|inkl|incluse?|incluido|included|ttc|inc)\b/ const TAX = /\b(tax|taxes|vat|tva|iva|mwst|mw\.?\s?st|ust|gst|hst|pst|qst|impuesto|imposto|steuer|tasse)\b/ const TIP = @@ -185,14 +185,10 @@ const DISCOUNT = /\b(discount|remise|rabatt|descuento|desconto|coupon|savings|you\s+saved|reduction|promo)\b/ const TOTAL = /\b(total|totale|totaal|gesamt\w*|summe|betrag|amount\s+due|balance\s+due|to\s+pay|a\s+pagar|zu\s+zahlen|montant|net\s+a\s+payer)\b/ -// A payment line starts with the payment word ("VISA 14.77", "Geg. BAR EUR 10,00"); one inside a -// name ("Birthday Card 4.99", "Bar snacks 4.00") is an item. const PAYMENT = - /^[^a-z\d]*((cash|change|card|gift\s*card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty|ec)\b|bar\s*((eur|chf)\s*)?[\d-])/ -// Which payment line it is: the change given back, a card, or cash handed over. + /\b(cash|change|card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|tendered|tend|payment|paid|paypal|rendu|rendue|monnaie|especes|carte|cb|wechselgeld|ruckgeld|gegeben|geg|karte|kartenzahlung|kontaktlos|girocard|cambio|efectivo|tarjeta|debito|credito|troco|dinheiro|cartao|pix|auth|approval|balance|points|loyalty)\b|^(bar|ec)\b|\bbar\s+(eur|chf)\b/ +// Cash handed over and the change given back: together they say what was paid. const CHANGE_GIVEN = /\b(change|rendu|rendue|monnaie|wechselgeld|ruckgeld|cambio|troco)\b/ -const CARD_PAID = - /\b(card|visa|mastercard|master\s*card|amex|maestro|debit|credit|contactless|paypal|carte|cb|karte|kartenzahlung|kontaktlos|girocard|tarjeta|debito|credito|cartao|pix)\b|^ec\b/ const CASH_PAID = /\b(cash|tendered|tend|especes|gegeben|geg|efectivo|dinheiro)\b|^bar\b|\bbar\s+(eur|chf)\b/ @@ -381,7 +377,8 @@ function itemOf(rest: string): { description: string; quantity: number } { /** * Whether the items, with tax and tip added and the discount taken off, come to the total: - * `exact`, `close` (within 5% of it), `off`, or null when there is no total or no item. Run again + * `exact`, `close` (within 5% of it), `off`, or null when there is no total or no item. A total + * worked out without a total line is `unconfirmed` unless the items are far off it. Run again * after the person corrects a price. */ export function receiptItemsMatch( @@ -392,14 +389,14 @@ export function receiptItemsMatch( ): ReceiptScan['itemsMatch'] { const total = scan.totalCents if (total === null || total <= 0 || scan.items.length === 0) return null - if (scan.totalDerived) return 'unconfirmed' const sum = scan.items.reduce((a, i) => a + i.amountCents, 0) + (scan.taxCents ?? 0) + (scan.tipCents ?? 0) - (scan.discountCents ?? 0) const diff = Math.abs(sum - total) - return diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' + const match = diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' + return scan.totalDerived && match !== 'off' ? 'unconfirmed' : match } // The parser ----------------------------------------------------------------------------------- @@ -430,13 +427,10 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // The line the last item came from, and a unit price line still waiting for its item. let lastItemLine = -2 let pendingQty: { count: number | null; unit: number } | null = null - // What the card or cash lines say was paid. + // What the card or cash lines say was paid, and the change given back. const paid: number[] = [] - const card: number[] = [] const cash: number[] = [] const change: number[] = [] - // Tax printed on top of the prices, not VAT they already include ("dont TVA", "IVA incluido"). - let addedTax = 0 for (const [index, line] of lines.entries()) { const key = keys[index]! @@ -463,7 +457,6 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip break case 'tax': tax = (tax ?? 0) + price.cents - if (!INCLUSIVE.test(key)) addedTax += price.cents break case 'tip': tip = (tip ?? 0) + price.cents @@ -472,10 +465,9 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip discount = (discount ?? 0) + price.cents break case 'payment': - paid.push(price.cents) if (CHANGE_GIVEN.test(key)) change.push(price.cents) - else if (CARD_PAID.test(key)) card.push(price.cents) - else if (CASH_PAID.test(key)) cash.push(price.cents) + else paid.push(price.cents) + if (CASH_PAID.test(key)) cash.push(price.cents) itemsEnd = Math.min(itemsEnd, index) break case 'item': { @@ -517,17 +509,10 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip pending = null } - // No total line read: what was paid (every card line, as a bill can be split, and the cash - // handed over less the change), else the subtotal with tax and tip on top, else the items. - // Never one item or the cash handed over alone. - const totalDerived = total === null - if (total === null) { - const sum = (amounts: number[]) => amounts.reduce((a, c) => a + c, 0) - const handedOver = cash.length > 0 && change.length > 0 ? sum(cash) - sum(change) : 0 - const paidOut = sum(card) + Math.max(handedOver, 0) - if (paidOut > 0) total = paidOut - else if (subtotal !== null) total = subtotal + addedTax + (tip ?? 0) - } + // No total line read: the subtotal, else the items, with nothing added on top. Payment lines + // never set it; one that says the same amount confirms it (below). + let totalDerived = total === null + total ??= subtotal // A line dearer than a printed total is a misread (a smudge read as 81.89). A total worked out // here cannot tell. const offered = items @@ -552,7 +537,13 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // A total line read a few cents off, while the card line says exactly what the items say: the // card line was the bill ("TOTAL $76.59" over "VISA $76.50" on a blurred photo). const formSum = itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) - if (offered.length > 0 && total !== formSum && paid.includes(formSum)) total = formSum + if (!totalDerived && offered.length > 0 && total !== formSum && paid.includes(formSum)) { + total = formSum + } + // A worked-out total the card paid, or the cash handed over less the change, is confirmed. + const sum = (amounts: number[]) => amounts.reduce((a, c) => a + c, 0) + const handedOver = cash.length > 0 && change.length > 0 ? sum(cash) - sum(change) : null + if (total !== null && (paid.includes(total) || handedOver === total)) totalDerived = false return { merchant: findMerchant(lines), From 7be207a5d6c1cb6e0641c9caaaaf54795c0b9e1a Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:20:32 -0500 Subject: [PATCH 25/29] count cash only net of change; balance lines never paid --- .../0021-receipt-scanning-and-attachments.md | 2 +- docs/receipts.md | 2 +- .../core/src/receipts/parse-receipt.test.ts | 34 +++++++++++++++++++ packages/core/src/receipts/parse-receipt.ts | 18 ++++++---- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index daae46c..8a3d071 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -10,7 +10,7 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, ## Decision - **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. -- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). Payment lines never supply a total; they only confirm one. With no total line, the total is the subtotal, or else the items, with nothing added on top. A card or cash line for the same amount (or the cash handed over less the change) confirms it; otherwise it is marked `totalDerived`, never counts as enough for the reader, ranks below a reading with a printed total, never drops an item as dearer than the bill, and the review says the total was not on the receipt (and that the items are off when a subtotal disagrees with them). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. +- **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). Payment lines never supply a total; they only confirm one. With no total line, the total is the subtotal, or else the items, with nothing added on top. A card line for the same amount, or the cash handed over (less the change, when a change line was read), confirms it (the same amounts are the only ones that can correct a misread printed total); card balance, points and approval lines never count as paid; otherwise it is marked `totalDerived`, never counts as enough for the reader, ranks below a reading with a printed total, never drops an item as dearer than the bill, and the review says the total was not on the receipt (and that the items are off when a subtotal disagrees with them). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. - **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. diff --git a/docs/receipts.md b/docs/receipts.md index 255b7b9..752fc1b 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -23,7 +23,7 @@ How a receipt photo becomes an expense, and how files are kept on expenses. The - OCR reads a lossless grayscale copy of the original photo with its contrast stretched (`ocrImage`), never the JPEG that is uploaded. When the items do not come to the total, or there was no total line and the total had to be worked out (that never counts as enough), a second pass reads an upscaled copy; when the best so far is still far off, a third reads the plain colour photo (which suits some long receipts better). The best reading wins (`bestScan`). - The upload copy (`prepareImage`) is at most 2000 px, JPEG on white. Re-encoding drops the photo's metadata, location included. HEIC outside Safari and PDFs go up as they are. - A scan only fills the form. The review sheet shows the photo and lets each price be corrected; the person saves the expense. -- The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off, or unconfirmed). Payment lines never supply a total, they only confirm one: with no total line the total is the subtotal, or else the items, with nothing added, and it stays `totalDerived` (the review asks the person to check it) unless a card or cash line, or the cash handed over less the change, says the same amount. +- The parser is heuristics with no SQL twin. A receipt it misreads becomes a case in `parse-receipt.test.ts`. It skips unit price lines ("2 Stk x 0,90"), payment lines and VAT summary rows, and reports `itemsMatch` (exact, close, off, or unconfirmed). Payment lines never supply a total, they only confirm one: with no total line the total is the subtotal, or else the items, with nothing added, and it stays `totalDerived` (the review asks the person to check it) unless a card line, or the cash handed over (less the change, when a change line was read), says the same amount. Card balance, points and approval lines never count as paid. - Tests mock `./ocr`: jsdom has no workers or canvas. ## Attachments in the database diff --git a/packages/core/src/receipts/parse-receipt.test.ts b/packages/core/src/receipts/parse-receipt.test.ts index 96b1fb7..0987637 100644 --- a/packages/core/src/receipts/parse-receipt.test.ts +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -607,6 +607,40 @@ describe('do the items match the total', () => { expect([cvs.totalCents, cvs.totalDerived, cvs.itemsMatch]).toEqual([649, true, 'unconfirmed']) }) + it('counts cash handed over only less the change it got back', () => { + // TOTAL 19.00, paid with 20.00 and 1.00 back; the fries (5.00) were misread as 6.00. + const lines = [ + 'Diner', + 'Burger 14.00', + 'Fries 6.00', + 'TOTAL 19.00', + 'CASH 20.00', + 'CHANGE 1.00', + ] + const read = parseReceipt(lines.join('\n'), { currency: 'USD' }) + expect([read.totalCents, read.itemsMatch]).toEqual([1900, 'off']) + // With the total line lost, the 20.00 the items make is not what was paid. + const lost = parseReceipt(lines.filter((l) => !l.startsWith('TOTAL')).join('\n'), { + currency: 'USD', + }) + expect([lost.totalCents, lost.totalDerived, lost.itemsMatch]).toEqual([ + 2000, + true, + 'unconfirmed', + ]) + }) + + it('never takes what is left on a card for what was paid', () => { + // TOTAL 19.00 for a 12.00 shirt and 7.00 socks misread as 8.00; the gift card has 20.00 left. + const lines = ['Shop', 'Shirt 12.00', 'Socks 8.00', 'TOTAL 19.00', 'GIFT CARD BALANCE 20.00'] + const read = parseReceipt(lines.join('\n'), { currency: 'USD' }) + expect([read.totalCents, read.itemsMatch]).toEqual([1900, 'off']) + const lost = parseReceipt(lines.filter((l) => !l.startsWith('TOTAL')).join('\n'), { + currency: 'USD', + }) + expect([lost.totalCents, lost.totalDerived]).toEqual([2000, true]) + }) + it('never adds up a bill paid in parts or a card balance', () => { // 12.00 + 7.00 is 19.00: 5.00 on a gift card that has 20.00 left, and 14.00 on VISA. const scan = parseReceipt( diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 6fef70a..983585a 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -191,6 +191,9 @@ const PAYMENT = const CHANGE_GIVEN = /\b(change|rendu|rendue|monnaie|wechselgeld|ruckgeld|cambio|troco)\b/ const CASH_PAID = /\b(cash|tendered|tend|especes|gegeben|geg|efectivo|dinheiro)\b|^bar\b|\bbar\s+(eur|chf)\b/ +// What is left on a card, points and approval codes: nothing to do with what this bill came to. +const NOT_PAID = + /\b(balance|bal|remaining|points|loyalty|auth|approval|solde|saldo|restguthaben|restbetrag)\b/ const NOT_AN_ITEM = /\b(tel|phone|fax|www|http|order|table|server|guest|guests|cashier|kasse|receipt|invoice|facture|rechnung|ticket|recibo|qty|items|articles|artikel|anzahl)\b/ @@ -427,7 +430,7 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip // The line the last item came from, and a unit price line still waiting for its item. let lastItemLine = -2 let pendingQty: { count: number | null; unit: number } | null = null - // What the card or cash lines say was paid, and the change given back. + // What the card lines say was paid, the cash handed over, and the change given back. const paid: number[] = [] const cash: number[] = [] const change: number[] = [] @@ -466,8 +469,8 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip break case 'payment': if (CHANGE_GIVEN.test(key)) change.push(price.cents) - else paid.push(price.cents) - if (CASH_PAID.test(key)) cash.push(price.cents) + else if (CASH_PAID.test(key)) cash.push(price.cents) + else if (!NOT_PAID.test(key)) paid.push(price.cents) itemsEnd = Math.min(itemsEnd, index) break case 'item': { @@ -534,16 +537,17 @@ export function parseReceipt(text: string, options: ParseReceiptOptions): Receip else if (sums(null, null, null)) [tax, tip, discount] = [null, null, null] } } + // What was paid: the card lines, and the cash handed over, less the change when there was any. + const sum = (amounts: number[]) => amounts.reduce((a, c) => a + c, 0) + if (cash.length > 0) paid.push(...(change.length > 0 ? [sum(cash) - sum(change)] : cash)) // A total line read a few cents off, while the card line says exactly what the items say: the // card line was the bill ("TOTAL $76.59" over "VISA $76.50" on a blurred photo). const formSum = itemsSum + (tax ?? 0) + (tip ?? 0) - (discount ?? 0) if (!totalDerived && offered.length > 0 && total !== formSum && paid.includes(formSum)) { total = formSum } - // A worked-out total the card paid, or the cash handed over less the change, is confirmed. - const sum = (amounts: number[]) => amounts.reduce((a, c) => a + c, 0) - const handedOver = cash.length > 0 && change.length > 0 ? sum(cash) - sum(change) : null - if (total !== null && (paid.includes(total) || handedOver === total)) totalDerived = false + // A worked-out total that was paid is confirmed. + if (total !== null && paid.includes(total)) totalDerived = false return { merchant: findMerchant(lines), From 766fc3f4d8f33d4984c6eda93abdad7fff0a87ee Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:25:36 -0500 Subject: [PATCH 26/29] derived scan total follows item edits; no filler line --- .../expenses/receipts/receipts.test.tsx | 19 +++++++++++++++++++ .../expenses/receipts/scan-fill.test.ts | 11 +++++++++++ .../features/expenses/receipts/scan-fill.ts | 5 ++++- .../expenses/receipts/scan-receipt.tsx | 4 +++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/receipts.test.tsx b/apps/web/src/features/expenses/receipts/receipts.test.tsx index fd8a1f3..83eb0d1 100644 --- a/apps/web/src/features/expenses/receipts/receipts.test.tsx +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -132,6 +132,25 @@ describe('ScanReceipt', () => { expect(within(sheet).getByText(t('expenses.scan.totalDerived'))).toBeInTheDocument() }) + it('keeps a total worked out from the items in step with a corrected price', async () => { + // The total line was lost and the fries (5.00) were misread as 6.00. + ocr.text = 'Diner\nBurger 14.00\nFries 6.00' + const { onApply, pick } = renderScan() + await pick() + const sheet = await screen.findByRole('dialog', { name: t('expenses.scan.reviewTitle') }) + fireEvent.change( + within(sheet).getByRole('textbox', { name: `${t('expenses.itemized.itemAmount')}, Fries` }), + { target: { value: '5.00' } }, + ) + fireEvent.click(within(sheet).getByRole('button', { name: t('expenses.scan.apply') })) + const [{ fill }] = onApply.mock.calls[0]! + expect(fill.amount).toBe(1900) + expect(fill.draft?.items.map((i) => [i.name, i.amountCents])).toEqual([ + ['Burger', 1400], + ['Fries', 500], + ]) + }) + it('says when the items are far off a subtotal taken for the total', async () => { ocr.text = 'Cafe\nTea 3.00\nSubtotal 7.00' const { pick } = renderScan() diff --git a/apps/web/src/features/expenses/receipts/scan-fill.test.ts b/apps/web/src/features/expenses/receipts/scan-fill.test.ts index 504fd19..0f9aa77 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.test.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -152,6 +152,17 @@ describe('fillFromScan', () => { expect(fill.amount).toBe(5617) }) + it('adds no line for the gap to a total that was not on the receipt', () => { + const fill = fillFromScan(scan({ tipCents: null, totalDerived: true }), all, { + draft, + currency: 'USD', + memberIds: people, + }) + expect(fill.draft?.items.map((i) => i.name)).toEqual(['2× Cheeseburger', 'Fries']) + // 25.00 + 19.50 for the items and 3.67 tax. + expect(fill.amount).toBe(4817) + }) + it('adds no line for a gap of a cent or so', () => { const fill = fillFromScan(scan({ totalCents: 5618 }), all, { draft, diff --git a/apps/web/src/features/expenses/receipts/scan-fill.ts b/apps/web/src/features/expenses/receipts/scan-fill.ts index 5994d72..5a6f36d 100644 --- a/apps/web/src/features/expenses/receipts/scan-fill.ts +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -106,8 +106,11 @@ export function fillFromScan( discountCents: scan.discountCents, tipPercent: null, } + // Only a total printed on the receipt can show a line the items missed. const missing = - choice.total && scan.totalCents !== null ? scan.totalCents - itemsTotal(next) : 0 + choice.total && scan.totalCents !== null && !scan.totalDerived + ? scan.totalCents - itemsTotal(next) + : 0 // A gap of a cent or so is a misread digit, not a forgotten line: no "Everything else" for it. if (missing > 0 && missing * 100 > (scan.totalCents ?? 0)) { next.items = [ diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index 5754a9e..f812034 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -357,9 +357,11 @@ function Review({ const itemsTotal = useMemo(() => scanItemsTotal(scan), [scan]) const toggle = (key: keyof ScanChoice) => (on: boolean | 'indeterminate') => onChoice({ ...choice, [key]: on === true }) - // A corrected price is checked against the total again straight away. + // A corrected price is checked against the total again straight away. A total that was only + // the items added up follows them. const edit = (patch: Partial) => { const next = { ...scan, ...patch } + if (next.totalDerived && next.subtotalCents === null) next.totalCents = scanItemsTotal(next) onScan({ ...next, itemsMatch: receiptItemsMatch(next) }) } const price = ( From cbb3dae55b630242d20f6c36c84b1ad57b687f9d Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:31:12 -0500 Subject: [PATCH 27/29] note derived scan totals in ADR 21 fill rules --- docs/adr/0021-receipt-scanning-and-attachments.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md index 8a3d071..c90fd40 100644 --- a/docs/adr/0021-receipt-scanning-and-attachments.md +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -11,7 +11,7 @@ Splitwise Pro reads a receipt photo into the total, the date and the line items, - **OCR runs in the browser.** `tesseract.js` (pinned to an exact version with its core and the English `best_int` model) reads the photo in a web worker. It is imported dynamically by the scan button only, so the app bundle and the service worker precache do not carry it. The worker, the WebAssembly core (LSTM builds for plain, SIMD and relaxed SIMD devices; each device fetches one) and the model are copied into the build under `/ocr//` by `apps/web/ocr-assets.ts` and served from our own origin, not a CDN. The service worker caches `/ocr/` on first use (`CacheFirst`, cache `potluck-ocr`), so after one scan it works offline. Each read starts a worker and terminates it, so its memory (around 100 MB) goes back at once. The reader sees a lossless grayscale copy of the original photo with its contrast stretched, never the JPEG that is uploaded (its compression smeared small print and lost items). When the items do not come to the total, it reads once more from an enlarged copy, and when that is still far off, from the plain colour photo; the best reading wins. - **Parsing is core code.** `parseReceipt` in `packages/core` turns OCR text into a merchant, a date, a currency, a total and items, with tax, tip and discount kept only when they make the receipt add up the way the by-item editor adds it (VAT already inside the prices is left out). Payment lines never supply a total; they only confirm one. With no total line, the total is the subtotal, or else the items, with nothing added on top. A card line for the same amount, or the cash handed over (less the change, when a change line was read), confirms it (the same amounts are the only ones that can correct a misread printed total); card balance, points and approval lines never count as paid; otherwise it is marked `totalDerived`, never counts as enough for the reader, ranks below a reading with a printed total, never drops an item as dearer than the bill, and the review says the total was not on the receipt (and that the items are off when a subtotal disagrees with them). It knows both decimal marks, VAT class letters, quantity lines, yen without decimals and month names in the five app languages, and is tested with receipts from seven countries. It is not a money rule: nothing in SQL mirrors it. -- **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there, and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. +- **A scan is a suggestion.** The review sheet shows the photo (pinch to zoom) beside each part with its own box, every price can be corrected there (a total that was only the items added up follows the corrections), and a warning says when the items do not come to the total (items that are far off start unticked). "Fill in the expense" sets the form (items go into the by-item editor with everyone on every line, a printed receipt total above its lines becomes an "Everything else" line, a receipt in another currency switches the form's currency); nothing is saved until the person saves. Photos are shrunk to 2000 px JPEG on the device before uploading (the reader works from the original), which also drops their metadata. - **Attachments are a table over the same bucket.** `expense_attachments (expense_id, position, group_id, path, content_type, uploaded_by)` holds up to five files per expense, in order. Files stay in the private `receipts` bucket under `/`, so the existing member read and upload, uploader or admin delete, and two-step restrictive policies cover them unchanged. Rows are written only by `set_expense_attachments` (who may edit the expense may change its files; every path in the group folder, uploaded, five at most), which logs one `expense.updated` with the counts. A new expense takes its files in the same call (`create_expense_with_attachments`), so its history has one entry and a refused file means nothing is created; no time window decides what counts as an edit. - **`receipt_path` stays the first attachment.** Both functions keep `receipt_path` equal to the first file. A trigger mirrors an older client's `receipt_path` writes into the list (its file is replaced or dropped, the others stay). Existing receipts were copied in as first attachments. From 83a49fcc00d84dda49a14b2440337ac48a1965f7 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:56:16 -0500 Subject: [PATCH 28/29] receipts: linear regexes, a fixed sweep error and memoised photo URLs 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> --- .../expenses/receipts/scan-receipt.tsx | 28 +++++++++--------- packages/core/src/receipts/parse-receipt.ts | 29 +++++++++++++++---- supabase/functions/receipts-sweep/index.ts | 4 ++- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index f812034..43a5d31 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -39,16 +39,16 @@ export interface ScanResult { type State = | { phase: 'idle' } - | { phase: 'reading'; photo: File; url: string; stage: ReadStage; progress: number } + | { phase: 'reading'; photo: File; original: File; stage: ReadStage; progress: number } | { phase: 'review' photo: File - url: string + original: File scan: ReceiptScan choice: ScanChoice attach: boolean } - | { phase: 'empty'; photo: File; url: string } + | { phase: 'empty'; photo: File; original: File } | { phase: 'failed'; reason: 'offline' | 'failed' } /** @@ -75,7 +75,9 @@ export function ScanReceipt({ const inputRef = useRef(null) const abortRef = useRef(null) const [state, setState] = useState({ phase: 'idle' }) - const url = 'url' in state ? state.url : null + // The photo as picked, shown while reading and beside the review. + const original = 'original' in state ? state.original : null + const url = useMemo(() => (original ? URL.createObjectURL(original) : null), [original]) useEffect( () => () => { if (url) URL.revokeObjectURL(url) @@ -89,8 +91,7 @@ export function ScanReceipt({ const controller = new AbortController() abortRef.current = controller // The reader works from the original photo; the shrunk JPEG is only what gets uploaded. - const photoUrl = URL.createObjectURL(picked) - setState({ phase: 'reading', photo: picked, url: photoUrl, stage: 'loading', progress: 0 }) + setState({ phase: 'reading', photo: picked, original: picked, stage: 'loading', progress: 0 }) const upload = prepareImage(picked) const parse = (text: string) => parseReceipt(text, { @@ -113,16 +114,15 @@ export function ScanReceipt({ ? { phase: 'review', photo, - url: photoUrl, + original: picked, scan, choice: defaultChoice(scan, description), attach: canAttach, } - : { phase: 'empty', photo, url: photoUrl }, + : { phase: 'empty', photo, original: picked }, ) } catch (error) { if (controller.signal.aborted) return - URL.revokeObjectURL(photoUrl) setState({ phase: 'failed', reason: error instanceof ReaderUnavailableError && !navigator.onLine ? 'offline' : 'failed', @@ -219,12 +219,12 @@ export function ScanReceipt({ size={state.phase === 'review' ? 'lg' : 'md'} footer={footer} > - {state.phase === 'reading' && ( - + {state.phase === 'reading' && url && ( + )} - {state.phase === 'review' && ( + {state.phase === 'review' && url && ( )} {state.phase === 'empty' && ( - {t('expenses.scan.nothing')} + {t('expenses.scan.nothing')} )} {state.phase === 'failed' && ( diff --git a/packages/core/src/receipts/parse-receipt.ts b/packages/core/src/receipts/parse-receipt.ts index 983585a..31162ee 100644 --- a/packages/core/src/receipts/parse-receipt.ts +++ b/packages/core/src/receipts/parse-receipt.ts @@ -144,8 +144,21 @@ const PRICE_AT_END = new RegExp( String.raw`(?:^|[\s:=])(-)?\s*(${SYMBOL})?\s*(-)?(${AMOUNT})\s*(${SYMBOL}|円)?\s*(-)?$`, 'u', ) -/** Trailing marks some tills print after a price: VAT class letters, "*", a currency code. */ -const TRAILING_MARKS = /(?:\s+(?:[A-Z]{1,2}\d?|[*#]))+\s*$/u +/** A mark some tills print after a price: a VAT class letter, "*", a currency code. */ +const TRAILING_MARK = /^(?:[A-Z]{1,2}\d?|[*#])$/u + +/** + * The line without the marks after its price. Words in a tidied line are one space apart; going + * word by word from the end keeps this linear however many marks a misread line has. + */ +function withoutTrailingMarks(line: string): string { + let end = line.length + for (let space = line.lastIndexOf(' '); space > 0; space = line.lastIndexOf(' ', end - 1)) { + if (!TRAILING_MARK.test(line.slice(space + 1, end))) break + end = space + } + return line.slice(0, end) +} interface Priced { cents: number @@ -155,7 +168,7 @@ interface Priced { } function trailingPrice(line: string, currency: string): Priced | null { - const trimmed = line.replace(TRAILING_MARKS, '') + const trimmed = withoutTrailingMarks(line) const match = PRICE_AT_END.exec(trimmed) if (!match) return null const [whole, minusBefore, symbolBefore, minusMiddle, token, symbolAfter, minusAfter] = match @@ -323,7 +336,9 @@ function findMerchant(lines: string[]): string | null { if (letters < 3 || numbers > letters) continue if (NOT_A_NAME.test(key) || /^\d+\s+\p{L}/u.test(line)) continue if (datesIn(key, 'dmy').length > 0) continue - const name = titleCase(line.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}).!']+$/gu, '').trim()) + const name = titleCase( + line.replace(/^[^\p{L}\p{N}]+|(?= 2) return name.slice(0, MERCHANT_MAX).trim() } return null @@ -337,7 +352,7 @@ const QTY_AT_END = new RegExp( String.raw`\s*(\d{1,3})\s*[x×@*]\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, 'iu', ) -const QTY_SUFFIX = /\s+[x×]\s*(\d{1,2})$/i +const QTY_SUFFIX = /(? stk x" (the count lost to the scan). It belongs to the item above it, or the one below. @@ -346,7 +361,9 @@ const QTY_LINE = /^[^\p{L}\d]*(\d{1,3})?\s*(?:stk|st|pcs?|pz|ea|un|uds?|und|units?)?\.?\s*[x×@*]\s*$/iu const UNIT_AT_END = new RegExp(String.raw`\s+@\s*(?:${SYMBOL})?\s*(?:${AMOUNT})\s*$`, 'u') -const JUNK_EDGES = /^[\s.,:;\-*#|>_~'"`«»[\]){}\\/]+|[\s.,:;\-*#|>_~'"`«»[\]{}\\/]+$/g +// A trailing run starts only after a character outside it, so no position is tried twice. +const JUNK_EDGES = + /^[\s.,:;\-*#|>_~'"`«»[\]){}\\/]+|(?_~'"`«»[\]{}\\/])[\s.,:;\-*#|>_~'"`«»[\]{}\\/]+$/g function itemOf(rest: string): { description: string; quantity: number } { let text = rest.replace(/^\d{4,}\s+/, '') diff --git a/supabase/functions/receipts-sweep/index.ts b/supabase/functions/receipts-sweep/index.ts index a91c587..4a2171b 100644 --- a/supabase/functions/receipts-sweep/index.ts +++ b/supabase/functions/receipts-sweep/index.ts @@ -18,6 +18,8 @@ Deno.serve(async (req) => { }) return json(result) } catch (error) { - return json({ error: String((error as { message?: string })?.message ?? error) }, 500) + // The cause goes to the function log, not back to the caller. + console.error('receipts sweep failed', error) + return json({ error: 'sweep_failed' }, 500) } }) From f82d0fb0f7204f9fc697205b098631e1504a8fa4 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:19:27 -0500 Subject: [PATCH 29/29] receipts: previews of a picked file are typed as images or opaque bytes 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> --- .../expenses/receipts/attachments-field.tsx | 4 +-- .../features/expenses/receipts/image.test.ts | 34 +++++++++++++++++-- .../src/features/expenses/receipts/image.ts | 10 ++++++ .../expenses/receipts/scan-receipt.tsx | 4 +-- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/expenses/receipts/attachments-field.tsx b/apps/web/src/features/expenses/receipts/attachments-field.tsx index 6d44e65..a626cd4 100644 --- a/apps/web/src/features/expenses/receipts/attachments-field.tsx +++ b/apps/web/src/features/expenses/receipts/attachments-field.tsx @@ -14,7 +14,7 @@ import { isPdf, type AttachmentDraft, } from './attachments-state' -import { prepareImage } from './image' +import { prepareImage, previewUrl } from './image' const MAX_BYTES = 10 * 1024 * 1024 @@ -159,7 +159,7 @@ function Tile({ index: number onRemove: () => void }) { - const local = useMemo(() => (draft.file ? URL.createObjectURL(draft.file) : null), [draft.file]) + const local = useMemo(() => (draft.file ? previewUrl(draft.file) : null), [draft.file]) useEffect( () => () => { if (local) URL.revokeObjectURL(local) diff --git a/apps/web/src/features/expenses/receipts/image.test.ts b/apps/web/src/features/expenses/receipts/image.test.ts index 608a808..f3d6294 100644 --- a/apps/web/src/features/expenses/receipts/image.test.ts +++ b/apps/web/src/features/expenses/receipts/image.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { ocrImage, ocrScale, stretchGray } from './image' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ocrImage, ocrScale, previewUrl, stretchGray } from './image' describe('the copy the reader sees', () => { it('keeps every pixel of a normal photo, cuts a huge one down, enlarges a small one once', () => { @@ -31,3 +31,33 @@ describe('the copy the reader sees', () => { expect(await ocrImage(heic, 2)).toBeNull() }) }) + +describe('the preview of a picked file', () => { + const real = URL.createObjectURL + afterEach(() => { + URL.createObjectURL = real + }) + + // A blob: URL is served with its blob's type, so a picked page must never keep text/html. + const shown = (file: File) => { + const make = vi.fn((_: Blob) => 'blob:preview') + URL.createObjectURL = make + expect(previewUrl(file)).toBe('blob:preview') + return make.mock.calls[0]![0] + } + + it('keeps a photo typed as the image it is, with the same bytes', async () => { + const blob = shown(new File(['jpeg bytes'], 'receipt.jpg', { type: 'image/jpeg' })) + expect(blob.type).toBe('image/jpeg') + expect(await blob.text()).toBe('jpeg bytes') + }) + + it('never serves a picked page or script as one', async () => { + const page = '' + for (const type of ['text/html', 'application/javascript', '']) { + const blob = shown(new File([page], 'receipt.html', { type })) + expect(blob.type).toBe('application/octet-stream') + expect(await blob.text()).toBe(page) + } + }) +}) diff --git a/apps/web/src/features/expenses/receipts/image.ts b/apps/web/src/features/expenses/receipts/image.ts index f620dac..e565827 100644 --- a/apps/web/src/features/expenses/receipts/image.ts +++ b/apps/web/src/features/expenses/receipts/image.ts @@ -13,6 +13,16 @@ export function fitWithin(width: number, height: number, max = MAX_EDGE) { const RESIZABLE = new Set(['image/jpeg', 'image/png', 'image/webp']) +/** + * An object URL for showing a picked file as a picture. The bytes are copied into a blob typed + * as the image the file says it is, or as opaque bytes otherwise, so the URL can never be read as + * a page or a script, whatever was picked. Revoke it with `URL.revokeObjectURL`. + */ +export function previewUrl(file: Blob): string { + const type = file.type.startsWith('image/') ? file.type : 'application/octet-stream' + return URL.createObjectURL(new Blob([file], { type })) +} + /** * Shrinks a photo before it is uploaded (OCR reads `ocrImage` instead): at most MAX_EDGE on its longest side, as JPEG * on white (a phone photo of a receipt goes from about 4 MB to about 400 KB). The camera's diff --git a/apps/web/src/features/expenses/receipts/scan-receipt.tsx b/apps/web/src/features/expenses/receipts/scan-receipt.tsx index 43a5d31..cb6725f 100644 --- a/apps/web/src/features/expenses/receipts/scan-receipt.tsx +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -17,7 +17,7 @@ import { todayIn } from '../format' import { CompactMoneyInput } from '../form/compact-inputs' import type { SplitDraft } from '../form/split-state' import { draftFromFile, type AttachmentDraft } from './attachments-state' -import { prepareImage } from './image' +import { prepareImage, previewUrl } from './image' import { ReaderUnavailableError, readReceiptText, type ReadStage } from './ocr' import { ZoomableImage } from './zoomable-image' import { @@ -77,7 +77,7 @@ export function ScanReceipt({ const [state, setState] = useState({ phase: 'idle' }) // The photo as picked, shown while reading and beside the review. const original = 'original' in state ? state.original : null - const url = useMemo(() => (original ? URL.createObjectURL(original) : null), [original]) + const url = useMemo(() => (original ? previewUrl(original) : null), [original]) useEffect( () => () => { if (url) URL.revokeObjectURL(url)