diff --git a/AGENTS.md b/AGENTS.md index f08f5494..544de1df 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/apps/web/e2e/fixtures/receipt.png b/apps/web/e2e/fixtures/receipt.png new file mode 100644 index 00000000..9bf0ddca Binary files /dev/null and b/apps/web/e2e/fixtures/receipt.png differ diff --git a/apps/web/e2e/receipts.spec.ts b/apps/web/e2e/receipts.spec.ts new file mode 100644 index 00000000..043142ec --- /dev/null +++ b/apps/web/e2e/receipts.spec.ts @@ -0,0 +1,120 @@ +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.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) + + // 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() + 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', { + 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/ocr-assets.ts b/apps/web/ocr-assets.ts new file mode 100644 index 00000000..d467cc06 --- /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 d3031176..6c09a60b 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 7e619362..5f6eedeb 100644 --- a/apps/web/src/features/expenses/expense-detail.test.tsx +++ b/apps/web/src/features/expenses/expense-detail.test.tsx @@ -347,24 +347,91 @@ 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.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 }) + 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 286920ea..96802cc7 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 00000000..e0a85500 --- /dev/null +++ b/apps/web/src/features/expenses/form/expense-form-receipts.test.tsx @@ -0,0 +1,388 @@ +import type * as Api from '@potluck/api' +import { + attachmentKeys, + createExpense, + createExpenseWithAttachments, + 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' + +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 { + ...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 })), + 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 } }) + }) +} + +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() +}) + +describe('expense form files', () => { + it('uploads every file and creates the expense with them in one 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('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) + 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('creates an expense without files the plain way', 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' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(onDone).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 () => { + 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('reuses uploads when a failed save is retried, and deletes them if the form closes', async () => { + vi.mocked(createExpenseWithAttachments).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(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(createExpenseWithAttachments).mock.calls + expect(second![2]).toEqual(first![2]) + view.unmount() + expect(vi.mocked(removeReceiptFiles)).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([first![2][0]]), + ) + }) + + it('deletes uploads for a save that never went through when the form closes', async () => { + vi.mocked(createExpenseWithAttachments).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(createExpenseWithAttachments)).toHaveBeenCalledTimes(1)) + const paths = vi.mocked(createExpenseWithAttachments).mock.calls[0]![2] + view.unmount() + expect(vi.mocked(removeReceiptFiles)).toHaveBeenCalledWith(expect.anything(), paths) + }) + + 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'), [])), + ) + 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 () => { + 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(createExpenseWithAttachments)).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(createExpenseWithAttachments).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] }, + ]) + }) + 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.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 03d99e7f..551dd6a8 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 a2715acd..2bf4dc70 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -1,13 +1,16 @@ import { avatarSrc } from '@/lib/queries' import { + attachmentQueries, categoryHistoryQueryOptions, createExpense, + createExpenseWithAttachments, + MAX_ATTACHMENTS, moneyKeys, moneyQueries, + removeReceiptFiles, setDefaultSplit, updateExpense, updateRecurring, - uploadReceipt, type ExpensePayload, type ExpenseWithSplits, type MoneyGroup, @@ -49,7 +52,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' @@ -77,8 +80,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 +280,33 @@ 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 : []) + // 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. 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(() => { + closed.current = false + return () => { + closed.current = true + if (!saveRunning.current) dropUploads() + } + }, []) const [recurrence, setRecurrence] = useState( template?.recurrence ?? (preset ? 'monthly' : 'none'), ) @@ -364,18 +398,41 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { }) const save = useMutation({ + onMutate: () => { + saveRunning.current = true + }, + onSettled: () => { + saveRunning.current = false + if (closed.current) dropUploads() + }, 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, uploaded.current) } catch { throw new Error('receipt_upload_failed') } } + // 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 + if (filesChanged && expense) { + 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 = { description: description.trim(), amount_cents: total, @@ -388,28 +445,33 @@ 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, { + const created: ExpensePayload = { ...payload, group_id: group.id, ...(props.mode === 'create' && props.eventId ? { event_id: props.eventId } : {}), ...(recurrence !== 'none' && !refund ? { recurrence } : {}), - }) + } + return withFiles( + paths.length > 0 + ? await createExpenseWithAttachments(supabase, created, paths) + : await createExpense(supabase, { ...created, receipt_path: null }), + ) }, onSuccess: async (saved) => { invalidateMoney(queryClient, group.id, saved.id) @@ -459,6 +521,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 && filesReady) 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 +876,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { {props.mode === 'recurring' && props.actions} ) : ( - + void storedFiles.refetch()} + retrying={storedFiles.isFetching} + /> )} {!editing && !refund && ( 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 23c53c1c..00000000 --- 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/apps/web/src/features/expenses/history.test.ts b/apps/web/src/features/expenses/history.test.ts index 1fc44542..235a2872 100644 --- a/apps/web/src/features/expenses/history.test.ts +++ b/apps/web/src/features/expenses/history.test.ts @@ -8,6 +8,15 @@ const nameOf = (id: string, o?: { lower?: boolean }) => 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 8ee3baa1..fcc49253 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 00000000..1fc4a542 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachment-gallery.tsx @@ -0,0 +1,237 @@ +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' +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, 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, + 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 ? ( + + + + {name} + + + + ) : ( + + ) + } + 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 ? ( + 1 ? onMove : undefined} + className="h-[calc(100dvh-11rem)] min-h-[50dvh] w-full" + frameClassName="min-h-0 flex-1 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 new file mode 100644 index 00000000..a626cd49 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachments-field.tsx @@ -0,0 +1,204 @@ +import { MAX_ATTACHMENTS, moneyQueries } from '@potluck/api' +import { t } from '@potluck/i18n' +import { Button, 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, previewUrl } from './image' + +const MAX_BYTES = 10 * 1024 * 1024 + +/** + * Receipts, photos and PDFs on the expense form: up to five, shrunk on the device first. While + * the files already on the expense are loading, or failed to load, nothing can be changed, so a + * save never replaces files the person never saw. + */ +export function AttachmentsField({ + value, + onChange, + status = 'ready', + onRetry, + retrying = false, +}: { + value: AttachmentDraft[] + onChange: (next: AttachmentDraft[]) => void + status?: 'ready' | 'loading' | 'failed' + onRetry?: () => void + retrying?: boolean +}) { + 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 })} + + )} + + +
+ {status === 'loading' && } + {status === 'failed' && ( +
+

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

+ +
+ )} + {status === 'ready' && ( + <> + { + 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 ? previewUrl(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 00000000..466669bf --- /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 00000000..6af2e646 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/attachments-state.ts @@ -0,0 +1,104 @@ +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 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(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 + }), + ) +} + +/** + * 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.test.ts b/apps/web/src/features/expenses/receipts/image.test.ts new file mode 100644 index 00000000..f3d6294e --- /dev/null +++ b/apps/web/src/features/expenses/receipts/image.test.ts @@ -0,0 +1,63 @@ +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', () => { + 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() + }) +}) + +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 new file mode 100644 index 00000000..e565827a --- /dev/null +++ b/apps/web/src/features/expenses/receipts/image.ts @@ -0,0 +1,145 @@ +/** 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']) + +/** + * 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 + * 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 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) return file + const name = `${file.name.replace(/\.[^.]*$/, '') || 'photo'}.jpg` + return new File([blob], name, { type: 'image/jpeg', lastModified: file.lastModified }) + } catch { + return file + } +} + +/** 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 00000000..86826f4c --- /dev/null +++ b/apps/web/src/features/expenses/receipts/ocr.test.ts @@ -0,0 +1,104 @@ +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((image: unknown) => { + tess.seen.push(image) + tess.logger?.({ status: 'recognizing text', progress: 1 }) + 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, + } + }, + ), +})) + +const copies = vi.hoisted(() => ({ first: 'native' as unknown })) +vi.mock('./image', () => ({ + ocrImage: vi.fn(async (_file: Blob, pass: 1 | 2) => (pass === 1 ? copies.first : 'closer')), +})) + +beforeEach(() => { + tess.texts = ['first', 'second', 'third'] + copies.first = 'native' + 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: (texts) => texts.includes('second'), + onProgress: (p) => stages.push(p.stage), + }) + expect(texts).toEqual(['first', 'second']) + expect(tess.seen).toEqual(['native', 'closer']) + 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('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 + 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) + }) + + 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 new file mode 100644 index 00000000..791010e5 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/ocr.ts @@ -0,0 +1,106 @@ +import type { Worker } from 'tesseract.js' +import { ocrImage } from './image' + +/** Fetching the reader, reading, or reading again closer up. */ +export type ReadStage = 'loading' | 'reading' | 'closer' + +export interface ReadProgress { + stage: ReadStage + /** 0 to 1 within the stage. */ + 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) { + 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. + * + * 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( + original: Blob, + { + 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() + const base = new URL(`${__OCR_PATH__}/`, window.location.origin).href + let pass = 1 + 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' ? 'loading' : pass === 1 ? 'reading' : 'closer', + 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 }) + }) + const texts: string[] = [] + const read = new Set() + const images = [() => ocrImage(original, 1), () => ocrImage(original, 2), async () => original] + try { + 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 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 + } + 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 new file mode 100644 index 00000000..83eb0d1a --- /dev/null +++ b/apps/web/src/features/expenses/receipts/receipts.test.tsx @@ -0,0 +1,264 @@ +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('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('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() + 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() + 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 00000000..0f9aa778 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-fill.test.ts @@ -0,0 +1,235 @@ +import { parseReceipt, type ReceiptScan } from '@potluck/core' +import { describe, expect, it } from 'vitest' +import { initialDraft, itemsTotal } from '../form/split-state' +import { + bestScan, + defaultChoice, + fillFromScan, + readingsEnough, + 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 }, + ], + totalDerived: false, + itemsMatch: 'exact', + ...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('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('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('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) + 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('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, + 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('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, + 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 }) + 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 00000000..5a6f36d5 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-fill.ts @@ -0,0 +1,128 @@ +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 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, unconfirmed: 0 } as const + +/** + * 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 && !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)) +} + +/** + * 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 +} + +/** 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, + } + // Only a total printed on the receipt can show a line the items missed. + const missing = + 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 = [ + ...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 00000000..cb6725f0 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/scan-receipt.tsx @@ -0,0 +1,535 @@ +import { parseReceipt, receiptDateOrder, receiptItemsMatch, 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 { 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 { draftFromFile, type AttachmentDraft } from './attachments-state' +import { prepareImage, previewUrl } from './image' +import { ReaderUnavailableError, readReceiptText, type ReadStage } from './ocr' +import { ZoomableImage } from './zoomable-image' +import { + bestScan, + defaultChoice, + fillFromScan, + readingsEnough, + 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; original: File; stage: ReadStage; progress: number } + | { + phase: 'review' + photo: File + original: File + scan: ReceiptScan + choice: ScanChoice + attach: boolean + } + | { phase: 'empty'; photo: File; original: File } + | { 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' }) + // The photo as picked, shown while reading and beside the review. + const original = 'original' in state ? state.original : null + const url = useMemo(() => (original ? previewUrl(original) : null), [original]) + 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 + // The reader works from the original photo; the shrunk JPEG is only what gets uploaded. + setState({ phase: 'reading', photo: picked, original: picked, stage: 'loading', progress: 0 }) + const upload = prepareImage(picked) + const parse = (text: string) => + parseReceipt(text, { + currency, + dateOrder: receiptDateOrder(currency, intlLocale()), + today: todayIn(timeZone), + }) + try { + const texts = await readReceiptText(picked, { + signal: controller.signal, + onProgress: ({ stage, progress }) => + setState((s) => (s.phase === 'reading' ? { ...s, stage, progress } : s)), + // 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 + setState( + scanFoundSomething(scan) + ? { + phase: 'review', + photo, + original: picked, + scan, + choice: defaultChoice(scan, description), + attach: canAttach, + } + : { phase: 'empty', photo, original: picked }, + ) + } catch (error) { + if (controller.signal.aborted) return + 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' && url && ( + + )} + {state.phase === 'review' && url && ( + setState({ ...state, scan })} + onChoice={(choice) => 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') + : stage === 'closer' + ? t('expenses.scan.closer') + : t('expenses.scan.reading') + const reading = stage !== 'loading' + 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 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 toggle = (key: keyof ScanChoice) => (on: boolean | 'indeterminate') => + onChoice({ ...choice, [key]: on === true }) + // 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 = ( + 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) => { + const name = + item.quantity > 1 + ? t('expenses.scan.quantity', { + count: item.quantity, + name: item.description, + }) + : 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')}

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

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

    + )} +
    + {scan.totalDerived && scan.totalCents !== null && ( +
    + +

    {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 && ( + 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 00000000..4886cb16 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.test.tsx @@ -0,0 +1,115 @@ +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('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() + 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 00000000..4d02c71c --- /dev/null +++ b/apps/web/src/features/expenses/receipts/use-zoom-pan.ts @@ -0,0 +1,204 @@ +import { + useCallback, + useRef, + useState, + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + 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 +/** 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)) + +/** 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 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 + // 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', + touchAction: 'none', + } + + return { + view, + style, + frameProps: { + ref: (element: HTMLElement | null) => { + frame.current = element + }, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel: onPointerUp, + onDoubleClick, + onKeyDown, + style: { touchAction: 'none' } as CSSProperties, + }, + zoomIn: () => step(1.5), + zoomOut: () => step(1 / 1.5), + reset, + } +} 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 00000000..186d5c57 --- /dev/null +++ b/apps/web/src/features/expenses/receipts/zoomable-image.tsx @@ -0,0 +1,78 @@ +import { t } from '@potluck/i18n' +import { IconButton, cn } from '@potluck/ui' +import { ZoomIn, ZoomOut } from 'lucide-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`. A new `src` starts unzoomed. + */ +export function ZoomableImage({ + src, + alt, + onSwipe, + className, + frameClassName, +}: { + src: string + alt: string + onSwipe?: (step: 1 | -1) => void + /** The whole block: frame and buttons. */ + className?: string + /** The frame the photo sits in: its height, border and background. */ + frameClassName?: string +}) { + 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 ( +
    +
    1 && 'cursor-grab', + frameClassName, + )} + > + {alt} +
    + + {t('expenses.attachments.zoomKeys')} + + + } + disabled={zoom.view.scale <= 1} + onClick={zoom.zoomOut} + className="disabled:text-mute/60 disabled:opacity-100" + /> + } + disabled={zoom.view.scale >= MAX_ZOOM} + onClick={zoom.zoomIn} + className="disabled:text-mute/60 disabled:opacity-100" + /> + +
    + ) +} diff --git a/apps/web/src/features/help/topics.ts b/apps/web/src/features/help/topics.ts index b27e9878..155fd26e 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 896d8334..048a21e6 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 ce3d5b71..7cce0a18 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 9d71e8e3..57bc68ba 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/docs/adr/0021-receipt-scanning-and-attachments.md b/docs/adr/0021-receipt-scanning-and-attachments.md new file mode 100644 index 00000000..c90fd402 --- /dev/null +++ b/docs/adr/0021-receipt-scanning-and-attachments.md @@ -0,0 +1,28 @@ +# 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. 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 (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. + +- **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. +- 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 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/adr/README.md b/docs/adr/README.md index e1fc7d3a..2f102d15 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/agent-map.json b/docs/agent-map.json index 3a3e16ab..3dbe6b73 100644 --- a/docs/agent-map.json +++ b/docs/agent-map.json @@ -266,7 +266,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5150, + "snapshotLine": 5377, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -284,7 +284,7 @@ "invite_used_up", "invite_wrong_email" ], - "snapshotLine": 5205, + "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": 5275, + "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": 5302, + "snapshotLine": 5529, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -314,7 +314,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5380, + "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": 5449, + "snapshotLine": 5676, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -334,7 +334,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 5486, + "snapshotLine": 5713, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -344,7 +344,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["event_done"], - "snapshotLine": 5515, + "snapshotLine": 5742, "definedIn": "supabase/migrations/20261105071521_rpc_events_timeline.sql" }, { @@ -354,7 +354,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5542, + "snapshotLine": 5769, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -364,7 +364,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5565, + "snapshotLine": 5792, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -374,9 +374,19 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 5579, + "snapshotLine": 5806, "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": 5817, + "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": 5859, "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": 5942, "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": 5980, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -419,7 +429,7 @@ "invalid_guest_limit", "invalid_guest_link_ttl" ], - "snapshotLine": 5803, + "snapshotLine": 6048, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -436,7 +446,7 @@ "invalid_role", "not_allowed" ], - "snapshotLine": 5878, + "snapshotLine": 6123, "definedIn": "supabase/migrations/20261105071508_rpc_invites.sql" }, { @@ -446,7 +456,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 5932, + "snapshotLine": 6177, "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": 6214, "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": 6252, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -476,7 +486,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["not_authenticated"], - "snapshotLine": 6058, + "snapshotLine": 6303, "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": 6407, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -496,7 +506,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["invalid_cursor"], - "snapshotLine": 6207, + "snapshotLine": 6452, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -506,7 +516,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6298, + "snapshotLine": 6543, "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": 6578, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -526,7 +536,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6423, + "snapshotLine": 6668, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -536,7 +546,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 6501, + "snapshotLine": 6746, "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": 6757, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -556,7 +566,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 6617, + "snapshotLine": 6862, "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": 6900, "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": 6949, "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": 7010, "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": 7120, "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": 7456, "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": 7517, "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": 7576, "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": 7668, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -653,7 +663,7 @@ "not_allowed", "split_member_not_in_group" ], - "snapshotLine": 7478, + "snapshotLine": 7723, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -663,7 +673,7 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7595, + "snapshotLine": 7840, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -673,7 +683,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7609, + "snapshotLine": 7854, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -683,7 +693,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7620, + "snapshotLine": 7865, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -693,7 +703,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7641, + "snapshotLine": 7886, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -703,7 +713,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7659, + "snapshotLine": 7904, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -713,7 +723,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7692, + "snapshotLine": 7937, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -723,7 +733,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 7718, + "snapshotLine": 7963, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -733,9 +743,19 @@ "securityDefiner": true, "execute": ["service_role"], "raises": [], - "snapshotLine": 7773, + "snapshotLine": 8018, "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": 8031, + "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": 8048, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -759,7 +779,7 @@ "note_too_long", "nothing_owed" ], - "snapshotLine": 7833, + "snapshotLine": 8095, "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": 8159, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -779,7 +799,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["friend_has_balance"], - "snapshotLine": 7940, + "snapshotLine": 8202, "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": 8229, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -805,7 +825,7 @@ "not_allowed", "use_leave_group" ], - "snapshotLine": 7999, + "snapshotLine": 8261, "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": 8308, "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": 8346, "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": 8415, "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": 8454, "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": 8501, "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": 8546, "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": 8597, "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": 8658, "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": 8692, "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": 8954, "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": 9043, "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": 9126, + "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": 9143, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -951,7 +981,7 @@ "not_allowed", "use_transfer_ownership" ], - "snapshotLine": 8932, + "snapshotLine": 9233, "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": 9273, "definedIn": "supabase/migrations/20261113100301_two_factor_storage_and_limits.sql" }, { @@ -971,7 +1001,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9009, + "snapshotLine": 9310, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -987,7 +1017,7 @@ "invalid_recipient", "member_not_found" ], - "snapshotLine": 9048, + "snapshotLine": 9349, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -997,7 +1027,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9106, + "snapshotLine": 9407, "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": 9444, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1017,7 +1047,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 9227, + "snapshotLine": 9528, "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": 9588, "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": 9637, "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": 9782, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1057,7 +1087,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9519, + "snapshotLine": 9820, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1067,7 +1097,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": ["expense_not_found"], - "snapshotLine": 9538, + "snapshotLine": 9839, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1083,7 +1113,7 @@ "placeholder_exists", "too_many_invites" ], - "snapshotLine": 9554, + "snapshotLine": 9855, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1108,7 +1138,7 @@ "split_member_not_in_group", "splits_sum_mismatch" ], - "snapshotLine": 9657, + "snapshotLine": 9958, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1124,7 +1154,7 @@ "settlement_deleted", "settlement_not_found" ], - "snapshotLine": 9914, + "snapshotLine": 10215, "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": 234, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, + { + "name": "private.attachment_item", + "args": "file_path text, who uuid", + "returns": "jsonb", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 245, + "definedIn": "supabase/migrations/20261114015104_expense_attachments.sql" + }, + { + "name": "private.attachment_list", + "args": "eid uuid", + "returns": "jsonb", + "securityDefiner": true, + "execute": [], + "raises": [], + "snapshotLine": 268, + "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": 290, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1166,7 +1234,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 170, + "snapshotLine": 308, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1176,7 +1244,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 203, + "snapshotLine": 341, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1186,7 +1254,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 223, + "snapshotLine": 361, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1196,7 +1264,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 242, + "snapshotLine": 380, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1206,7 +1274,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["payers_sum_mismatch"], - "snapshotLine": 254, + "snapshotLine": 392, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1216,7 +1284,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["splits_sum_mismatch"], - "snapshotLine": 294, + "snapshotLine": 432, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1226,7 +1294,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": ["group_owner_required"], - "snapshotLine": 335, + "snapshotLine": 473, "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": 512, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -1246,7 +1314,7 @@ "securityDefiner": false, "execute": ["anon", "authenticated", "service_role"], "raises": ["mfa_required"], - "snapshotLine": 406, + "snapshotLine": 544, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -1261,7 +1329,7 @@ "placeholder_claim_conflict", "placeholder_not_found" ], - "snapshotLine": 424, + "snapshotLine": 562, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1271,7 +1339,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 568, + "snapshotLine": 706, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1281,7 +1349,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 606, + "snapshotLine": 744, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1291,7 +1359,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 617, + "snapshotLine": 755, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1301,7 +1369,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 638, + "snapshotLine": 776, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1311,7 +1379,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["not_allowed"], - "snapshotLine": 658, + "snapshotLine": 796, "definedIn": "supabase/migrations/20261105071501_rls_platform.sql" }, { @@ -1321,7 +1389,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 676, + "snapshotLine": 814, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1331,7 +1399,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 734, + "snapshotLine": 872, "definedIn": "supabase/migrations/20261113115257_payment_comments.sql" }, { @@ -1347,7 +1415,7 @@ "negative_share", "splits_required" ], - "snapshotLine": 754, + "snapshotLine": 892, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1366,7 +1434,7 @@ "splits_sum_mismatch", "zero_shares" ], - "snapshotLine": 831, + "snapshotLine": 969, "definedIn": "supabase/migrations/20261105071511_rpc_expenses.sql" }, { @@ -1376,7 +1444,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 947, + "snapshotLine": 1085, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1386,7 +1454,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 955, + "snapshotLine": 1093, "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": 1174, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1406,7 +1474,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1063, + "snapshotLine": 1201, "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": 1282, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1426,7 +1494,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1210, + "snapshotLine": 1348, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1436,7 +1504,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1238, + "snapshotLine": 1376, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1446,7 +1514,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1278, + "snapshotLine": 1416, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -1456,7 +1524,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1308, + "snapshotLine": 1446, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1466,7 +1534,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1337, + "snapshotLine": 1475, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1476,7 +1544,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1388, + "snapshotLine": 1526, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1486,7 +1554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1434, + "snapshotLine": 1572, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1496,7 +1564,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1480, + "snapshotLine": 1618, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1506,7 +1574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1538, + "snapshotLine": 1676, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1516,7 +1584,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1692, + "snapshotLine": 1830, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -1526,7 +1594,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1711, + "snapshotLine": 1849, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -1536,7 +1604,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1726, + "snapshotLine": 1864, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1546,7 +1614,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1740, + "snapshotLine": 1878, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1556,7 +1624,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1755, + "snapshotLine": 1893, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1566,7 +1634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1772, + "snapshotLine": 1910, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1576,7 +1644,7 @@ "securityDefiner": true, "execute": [], "raises": ["not_a_friend"], - "snapshotLine": 1798, + "snapshotLine": 1936, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -1586,7 +1654,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1840, + "snapshotLine": 1978, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1596,7 +1664,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1873, + "snapshotLine": 2011, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -1606,7 +1674,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 1890, + "snapshotLine": 2028, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1616,7 +1684,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1901, + "snapshotLine": 2039, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1626,7 +1694,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 1943, + "snapshotLine": 2081, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1636,7 +1704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1973, + "snapshotLine": 2111, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1646,7 +1714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 1999, + "snapshotLine": 2137, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1656,7 +1724,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2013, + "snapshotLine": 2151, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1666,7 +1734,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2026, + "snapshotLine": 2164, "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": 2188, "definedIn": "supabase/migrations/20261105071427_money_tables.sql" }, { @@ -1686,7 +1754,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_has_balances"], - "snapshotLine": 2072, + "snapshotLine": 2210, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1696,7 +1764,7 @@ "securityDefiner": true, "execute": [], "raises": ["rsvp_closed"], - "snapshotLine": 2098, + "snapshotLine": 2236, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1706,7 +1774,7 @@ "securityDefiner": true, "execute": [], "raises": ["placeholder_member"], - "snapshotLine": 2138, + "snapshotLine": 2276, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1716,7 +1784,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2196, + "snapshotLine": 2334, "definedIn": "supabase/migrations/20261113170311_guest_links.sql" }, { @@ -1726,7 +1794,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2212, + "snapshotLine": 2350, "definedIn": "supabase/migrations/20261105071417_profiles.sql" }, { @@ -1736,7 +1804,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2244, + "snapshotLine": 2382, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -1746,7 +1814,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2256, + "snapshotLine": 2394, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1756,7 +1824,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2273, + "snapshotLine": 2411, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1766,7 +1834,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2289, + "snapshotLine": 2427, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1776,7 +1844,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2305, + "snapshotLine": 2443, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1786,7 +1854,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2322, + "snapshotLine": 2460, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1796,7 +1864,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2337, + "snapshotLine": 2475, "definedIn": "supabase/migrations/20261113204239_export_and_import.sql" }, { @@ -1806,7 +1874,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2350, + "snapshotLine": 2488, "definedIn": "supabase/migrations/20261113172837_search_insights.sql" }, { @@ -1816,7 +1884,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2379, + "snapshotLine": 2517, "definedIn": "supabase/migrations/20261105160000_edge_function_hooks.sql" }, { @@ -1826,7 +1894,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2406, + "snapshotLine": 2544, "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": 2562, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -1846,7 +1914,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2439, + "snapshotLine": 2577, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -1856,7 +1924,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 1077, + "snapshotLine": 1215, "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": 2588, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1876,7 +1944,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2548, + "snapshotLine": 2686, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -1886,7 +1954,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2559, + "snapshotLine": 2697, "definedIn": "supabase/migrations/20261105071528_activity_triggers.sql" }, { @@ -1896,7 +1964,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found", "not_allowed"], - "snapshotLine": 2577, + "snapshotLine": 2715, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -1906,7 +1974,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2603, + "snapshotLine": 2741, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -1916,7 +1984,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2617, + "snapshotLine": 2755, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -1926,7 +1994,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2725, + "snapshotLine": 2863, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -1936,7 +2004,7 @@ "securityDefiner": true, "execute": [], "raises": ["event_not_found"], - "snapshotLine": 2739, + "snapshotLine": 2877, "definedIn": "supabase/migrations/20261105071518_rpc_availability.sql" }, { @@ -1946,7 +2014,7 @@ "securityDefiner": true, "execute": ["anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2759, + "snapshotLine": 2897, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -1956,7 +2024,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 939, + "snapshotLine": 1077, "definedIn": "supabase/migrations/20261105210000_query_performance.sql" }, { @@ -1966,7 +2034,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2775, + "snapshotLine": 2913, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1976,7 +2044,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2841, + "snapshotLine": 2979, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -1986,7 +2054,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 2857, + "snapshotLine": 2995, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -1996,7 +2064,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2871, + "snapshotLine": 3009, "definedIn": "supabase/migrations/20261113213127_deleted_groups_everywhere.sql" }, { @@ -2006,7 +2074,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2886, + "snapshotLine": 3024, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2016,7 +2084,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 2900, + "snapshotLine": 3038, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2026,7 +2094,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2914, + "snapshotLine": 3052, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2036,7 +2104,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 2929, + "snapshotLine": 3067, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2046,7 +2114,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 2946, + "snapshotLine": 3084, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2056,7 +2124,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 2986, + "snapshotLine": 3124, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2066,7 +2134,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3004, + "snapshotLine": 3142, "definedIn": "supabase/migrations/20261113115301_group_debts_and_bulk_settle.sql" }, { @@ -2076,7 +2144,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3219, + "snapshotLine": 3357, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2086,7 +2154,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3257, + "snapshotLine": 3395, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2096,7 +2164,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_currency"], - "snapshotLine": 3282, + "snapshotLine": 3420, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2106,7 +2174,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3300, + "snapshotLine": 3438, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2116,7 +2184,7 @@ "securityDefiner": true, "execute": [], "raises": ["fx_rate_unavailable"], - "snapshotLine": 3311, + "snapshotLine": 3449, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2126,7 +2194,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["invalid_payment_handle"], - "snapshotLine": 3332, + "snapshotLine": 3470, "definedIn": "supabase/migrations/20261113115259_payment_methods.sql" }, { @@ -2136,7 +2204,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": ["note_too_long"], - "snapshotLine": 3352, + "snapshotLine": 3490, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2146,7 +2214,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3370, + "snapshotLine": 3508, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2156,7 +2224,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_email"], - "snapshotLine": 3384, + "snapshotLine": 3522, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2166,7 +2234,7 @@ "securityDefiner": false, "execute": [], "raises": ["invalid_name"], - "snapshotLine": 3402, + "snapshotLine": 3540, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2176,7 +2244,7 @@ "securityDefiner": true, "execute": [], "raises": ["profile_without_account"], - "snapshotLine": 3420, + "snapshotLine": 3558, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2186,7 +2254,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3439, + "snapshotLine": 3577, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2196,7 +2264,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3471, + "snapshotLine": 3609, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2206,7 +2274,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3490, + "snapshotLine": 3628, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2216,9 +2284,19 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 3514, + "snapshotLine": 3652, "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": 3663, + "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": 3689, "definedIn": "supabase/migrations/20261113100257_two_factor_enforcement.sql" }, { @@ -2236,7 +2314,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3539, + "snapshotLine": 3703, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2246,7 +2324,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3552, + "snapshotLine": 3716, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2256,7 +2334,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3564, + "snapshotLine": 3728, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2266,7 +2344,7 @@ "securityDefiner": false, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 3579, + "snapshotLine": 3743, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2276,7 +2354,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3595, + "snapshotLine": 3759, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2286,7 +2364,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3617, + "snapshotLine": 3781, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2296,7 +2374,7 @@ "securityDefiner": true, "execute": [], "raises": ["split_member_not_in_group"], - "snapshotLine": 3632, + "snapshotLine": 3796, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2306,7 +2384,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3651, + "snapshotLine": 3815, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2316,7 +2394,7 @@ "securityDefiner": false, "execute": [], "raises": [], - "snapshotLine": 3679, + "snapshotLine": 3843, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2326,7 +2404,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3701, + "snapshotLine": 3865, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2336,7 +2414,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3734, + "snapshotLine": 3898, "definedIn": "supabase/migrations/20261113201107_friends_and_blocks.sql" }, { @@ -2346,7 +2424,7 @@ "securityDefiner": true, "execute": [], "raises": ["direct_group", "not_allowed"], - "snapshotLine": 3776, + "snapshotLine": 3940, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2356,7 +2434,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 3800, + "snapshotLine": 3964, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2366,7 +2444,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_deleted"], - "snapshotLine": 3815, + "snapshotLine": 3979, "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": 3995, "definedIn": "supabase/migrations/20261113213126_group_types_restore_and_whiteboard.sql" }, { @@ -2386,7 +2464,7 @@ "securityDefiner": true, "execute": [], "raises": ["group_archived"], - "snapshotLine": 3858, + "snapshotLine": 4022, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2396,7 +2474,7 @@ "securityDefiner": true, "execute": [], "raises": ["account_deleted", "not_authenticated"], - "snapshotLine": 3873, + "snapshotLine": 4037, "definedIn": "supabase/migrations/20261105071504_rpc_groups.sql" }, { @@ -2406,7 +2484,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 3894, + "snapshotLine": 4058, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2416,7 +2494,7 @@ "securityDefiner": true, "execute": ["authenticated", "service_role"], "raises": [], - "snapshotLine": 3911, + "snapshotLine": 4075, "definedIn": "supabase/migrations/20261106000000_review_fixes.sql" }, { @@ -2446,7 +2524,7 @@ "splits_required", "splits_sum_mismatch" ], - "snapshotLine": 3927, + "snapshotLine": 4091, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2456,7 +2534,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4431, + "snapshotLine": 4595, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2466,7 +2544,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4450, + "snapshotLine": 4614, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2476,7 +2554,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4636, + "snapshotLine": 4800, "definedIn": "supabase/migrations/20261105071542_cron_jobs.sql" }, { @@ -2486,7 +2564,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4687, + "snapshotLine": 4851, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2496,7 +2574,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4720, + "snapshotLine": 4884, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2506,7 +2584,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4801, + "snapshotLine": 4965, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2516,7 +2594,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4818, + "snapshotLine": 4982, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2526,7 +2604,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4858, + "snapshotLine": 5022, "definedIn": "supabase/migrations/20261105071531_notification_triggers.sql" }, { @@ -2536,7 +2614,7 @@ "securityDefiner": true, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 4884, + "snapshotLine": 5048, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2546,7 +2624,7 @@ "securityDefiner": true, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 4928, + "snapshotLine": 5092, "definedIn": "supabase/migrations/20261105071444_policy_helpers.sql" }, { @@ -2556,7 +2634,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 4944, + "snapshotLine": 5108, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2566,7 +2644,7 @@ "securityDefiner": true, "execute": [], "raises": ["too_many_invites"], - "snapshotLine": 4980, + "snapshotLine": 5144, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, { @@ -2576,9 +2654,19 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5000, + "snapshotLine": 5164, "definedIn": "supabase/migrations/20261113201103_placeholder_members.sql" }, + { + "name": "private.sync_receipt_attachment", + "args": "", + "returns": "trigger", + "securityDefiner": true, + "execute": ["PUBLIC"], + "raises": [], + "snapshotLine": 5184, + "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": 5229, "definedIn": "supabase/migrations/20261113091955_expense_entry.sql" }, { @@ -2596,7 +2684,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5038, + "snapshotLine": 5247, "definedIn": "supabase/migrations/20261106010000_integrity_fixes.sql" }, { @@ -2606,7 +2694,7 @@ "securityDefiner": false, "execute": ["PUBLIC", "anon", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5066, + "snapshotLine": 5275, "definedIn": "supabase/migrations/20261105071410_extensions_and_private_schema.sql" }, { @@ -2616,7 +2704,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5082, + "snapshotLine": 5291, "definedIn": "supabase/migrations/20261113062656_notification_prefs_and_email.sql" }, { @@ -2626,7 +2714,7 @@ "securityDefiner": true, "execute": [], "raises": [], - "snapshotLine": 5102, + "snapshotLine": 5311, "definedIn": "supabase/migrations/20261113153242_recurring_editing.sql" }, { @@ -2636,7 +2724,7 @@ "securityDefiner": false, "execute": ["PUBLIC"], "raises": [], - "snapshotLine": 5115, + "snapshotLine": 5324, "definedIn": "supabase/migrations/20261113115254_payments_v2.sql" }, { @@ -2646,8 +2734,18 @@ "securityDefiner": false, "execute": ["PUBLIC", "authenticated", "service_role"], "raises": [], - "snapshotLine": 5126, + "snapshotLine": 5335, "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": 5359, + "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 162b6c6f..6564df90 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. @@ -778,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. @@ -866,7 +879,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 +937,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 +948,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 +989,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 +1021,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/deployment.md b/docs/deployment.md index 8d67567c..0b305349 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/development.md b/docs/development.md index 88b673bc..4325e1db 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 new file mode 100644 index 00000000..752fc1b4 --- /dev/null +++ b/docs/receipts.md @@ -0,0 +1,38 @@ +# 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, 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 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 + +- `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 (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 (`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 51f0c712..ef19a39d 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, 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. @@ -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 d9b51fdb..f399c58e 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,69 @@ 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 `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 + 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: 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 + 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 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) + 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 + 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 + par each new file + W->>S: upload receipts/:groupId/:file (policy: member, two-step done) + end + 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}} + 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) +``` + +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 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 3ecf9b8a..f1b34890 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 2a93b130..d17d086d 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. diff --git a/packages/api/src/attachments.test.ts b/packages/api/src/attachments.test.ts new file mode 100644 index 00000000..2000bf28 --- /dev/null +++ b/packages/api/src/attachments.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + createExpenseWithAttachments, + 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('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, []) + 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 00000000..06f80247 --- /dev/null +++ b/packages/api/src/attachments.ts @@ -0,0 +1,90 @@ +/** + * 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, Json } from './database.types' +import type { PotluckClient } from './index' +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< + 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 })) +} + +/** + * 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. + */ +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/database.types.ts b/packages/api/src/database.types.ts index 8c04fcbd..f55e6e1e 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 @@ -1903,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: { @@ -2324,6 +2410,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 +2633,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/packages/api/src/index.ts b/packages/api/src/index.ts index 508732d2..50afe948 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/packages/core/src/index.ts b/packages/core/src/index.ts index 3752426a..fe9c6f6a 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 00000000..80103857 --- /dev/null +++ b/packages/core/src/receipts/index.ts @@ -0,0 +1,10 @@ +export { + MAX_RECEIPT_ITEMS, + parseReceipt, + parseReceiptAmount, + receiptDateOrder, + receiptItemsMatch, + 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 00000000..0987637b --- /dev/null +++ b/packages/core/src/receipts/parse-receipt.test.ts @@ -0,0 +1,741 @@ +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, + totalDerived: false, + itemsMatch: 'exact', + 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('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([ + ['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: [], + totalDerived: false, + itemsMatch: null, + }) + 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]]) + }) +}) + +// 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('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 + + 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('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('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, 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, tendered.itemsMatch]).toEqual([false, 'exact']) + expect(items(tendered.items)).toEqual([ + ['Burger', 1400, 1], + ['Fries', 500, 1], + ]) + // 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([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', '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', '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 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 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, scan.tipCents]).toEqual([null, null]) + expect(scan.totalDerived).toBe(derived) + }, + ) + + 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(1900) + expect(scan.taxCents).toBeNull() + expect([scan.totalDerived, scan.itemsMatch]).toEqual([true, 'off']) + }) + + 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('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(items(cvs.items)).toEqual([['Shampoo', 649, 1]]) + 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( + [ + '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], + ]) + }) + + 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', '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, scan.itemsMatch]).toEqual([true, 'off']) + }) + + 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 + + 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 00000000..31162eeb --- /dev/null +++ b/packages/core/src/receipts/parse-receipt.ts @@ -0,0 +1,595 @@ +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[] + /** + * 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 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 +} + +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(/\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', +) +/** 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 + negative: boolean + /** The line without the price. */ + rest: string +} + +function trailingPrice(line: string, currency: string): Priced | null { + const trimmed = withoutTrailingMarks(line) + 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|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 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/ + +/** 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' + 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) + 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, + )) { + 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}]+|(?= 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 = /(? 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') + +// 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+/, '') + 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) + } + // 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), + } +} + +/** + * 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. 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( + 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 + 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) + const match = diff === 0 ? 'exact' : diff * 20 <= total ? 'close' : 'off' + return scan.totalDerived && match !== 'off' ? 'unconfirmed' : match +} + +// 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 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 + // 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[] = [] + + 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) + if (kind === 'table') { + pending = null + continue + } + 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': + if (CHANGE_GIVEN.test(key)) change.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': { + if (index > itemsEnd || NOT_AN_ITEM.test(key)) break + if (price.negative) { + 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 + // 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 + } + } + pending = null + } + + // 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 + .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 + 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] + } + } + // 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 that was paid is confirmed. + if (total !== null && paid.includes(total)) totalDerived = false + + 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, + totalDerived: totalDerived && total !== null, + itemsMatch: receiptItemsMatch({ + totalCents: total, + totalDerived, + taxCents: tax, + tipCents: tip, + discountCents: discount, + items: offered, + }), + } +} diff --git a/packages/i18n/src/de/help.ts b/packages/i18n/src/de/help.ts index 3678169f..31f1239b 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 869c9ad4..5804c062 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,71 @@ 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', + 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}', + 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.', + zoomIn: 'Vergrößern', + zoomOut: 'Verkleinern', + zoomKeys: 'Mit + und - zoomen, mit den Pfeiltasten verschieben, 0 setzt zurück.', + }, + 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}.', + 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', + 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.', + 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 export const categories = { diff --git a/packages/i18n/src/en/help.ts b/packages/i18n/src/en/help.ts index ff4def6a..11e9f14b 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 78d0e14f..05dec142 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,68 @@ 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', + 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}', + 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.', + 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: { + 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}.', + 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', + 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.', + 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 export const categories = { diff --git a/packages/i18n/src/es/help.ts b/packages/i18n/src/es/help.ts index 8e6d7adb..d95b5f35 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 0ff9bcaa..4d3a63b4 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,70 @@ 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', + 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}', + 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.', + zoomIn: 'Acercar', + zoomOut: 'Alejar', + zoomKeys: 'Acerca con + y -, muévete con las flechas, 0 para volver al inicio.', + }, + 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}.', + 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', + 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é.', + 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 export const categories = { diff --git a/packages/i18n/src/fr/help.ts b/packages/i18n/src/fr/help.ts index b9fa9e53..e21236a7 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 da628bc3..97cd196f 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,72 @@ 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', + 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}', + 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.", + 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', + 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}.', + 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', + 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.', + 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 export const categories = { diff --git a/packages/i18n/src/pt/help.ts b/packages/i18n/src/pt/help.ts index 918e9657..9773f59d 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 95ca1d46..7bae0c50 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,68 @@ 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', + 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}', + 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.', + zoomIn: 'Aproximar', + zoomOut: 'Afastar', + zoomKeys: 'Aproxime com + e -, mova com as setas, 0 para voltar ao início.', + }, + 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}.', + 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', + 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ê.', + 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 export const categories = { diff --git a/packages/ui/src/sheet.tsx b/packages/ui/src/sheet.tsx index 051743df..7aaa0e3b 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 56efb937..5deeb0d9 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 diff --git a/supabase/config.toml b/supabase/config.toml index d4875dc7..4d89e919 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 00000000..4a2171bb --- /dev/null +++ b/supabase/functions/receipts-sweep/index.ts @@ -0,0 +1,25 @@ +// 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) => { + // 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 { + const result = await sweepOrphans({ + rpc: (fn, args) => admin.rpc(fn, args), + remove: (paths) => admin.storage.from('receipts').remove(paths), + }) + return json(result) + } catch (error) { + // The cause goes to the function log, not back to the caller. + console.error('receipts sweep failed', error) + return json({ error: 'sweep_failed' }, 500) + } +}) diff --git a/supabase/functions/receipts-sweep/sweep.test.ts b/supabase/functions/receipts-sweep/sweep.test.ts new file mode 100644 index 00000000..615f4cf9 --- /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 00000000..eb114948 --- /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/20261114015104_expense_attachments.sql b/supabase/migrations/20261114015104_expense_attachments.sql new file mode 100644 index 00000000..3cff0c7b --- /dev/null +++ b/supabase/migrations/20261114015104_expense_attachments.sql @@ -0,0 +1,341 @@ +-- 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 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. +-- 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 RPCs --------------------------------------------------------------------------------------- + +-- 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 +-- 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(); + 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 #>> '{}' + 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; +$$; + +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 = 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 +-- 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/schema/current.sql b/supabase/schema/current.sql index b2e1ca1d..bd313b82 100644 --- a/supabase/schema/current.sql +++ b/supabase/schema/current.sql @@ -138,6 +138,99 @@ $$; 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 #>> '{}' + 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 +242,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 +3660,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 +5181,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 +5356,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 +5814,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 +8028,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 +9101,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 +11363,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 +11643,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 +11915,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 +12321,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 +12753,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 +13015,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 +13458,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 +13610,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 +13680,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 +13999,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 +14169,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 +14671,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 40edd44c..90fac531 100644 --- a/supabase/tests/01_schema.test.sql +++ b/supabase/tests/01_schema.test.sql @@ -10,12 +10,12 @@ -- 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(130); 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 +76,9 @@ 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', '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'); @@ -266,6 +269,10 @@ 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' +); -- Grants: clients never write money tables directly. select ok(not has_table_privilege('authenticated', 'public.expenses', 'insert'), 'no direct expense inserts'); diff --git a/supabase/tests/29_expense_attachments.test.sql b/supabase/tests/29_expense_attachments.test.sql new file mode 100644 index 00000000..8f188716 --- /dev/null +++ b/supabase/tests/29_expense_attachments.test.sql @@ -0,0 +1,389 @@ +-- 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(59); + +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, + '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'); +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, + 'the same list changes nothing and logs nothing' +); + +-- 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'), + pg_temp.files('one.jpg', 'four.webp'))), + 2, + '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'); + +-- 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' +); +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')), + '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'); + +-- 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'); + +-- 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;