Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
80121e3
read receipt text into a total, date, merchant and items
musa-kal Nov 13, 2026
7811b89
receipt parser: plain whitespace class and const reads
musa-kal Nov 13, 2026
2ab8ed1
several attachments per expense
musa-kal Nov 13, 2026
e19abc5
strings for receipt scanning and attachments in every language
musa-kal Nov 13, 2026
8d150d9
scan a receipt on the device and attach up to five files
musa-kal Nov 13, 2026
c48822b
receipts e2e on phone and desktop, and the attachments pgTAP plan
musa-kal Nov 13, 2026
e87f5af
document receipt scanning and attachments
musa-kal Nov 13, 2026
e283ac4
receipt parser: unit price lines, payment lines, VAT tables, items vs…
musa-kal Nov 13, 2026
96c878c
one history entry for files sent with a new expense, and a daily orph…
musa-kal Nov 13, 2026
bef4191
scan from the original photo, show it in the review, and let every pr…
musa-kal Nov 13, 2026
a1d4806
zoom and swipe in the photo viewer, and name PDFs the same everywhere
musa-kal Nov 13, 2026
dddea84
reuse uploads on a retried save, delete the ones nothing kept
musa-kal Nov 13, 2026
8136826
receipts docs: the reading, cleanup and sweep, and comments stay text…
musa-kal Nov 13, 2026
2774fa7
create an expense with its files in one call; every later change is l…
musa-kal Nov 13, 2026
269c895
read a far-off receipt a third time in colour, and clean up its lines
musa-kal Nov 13, 2026
bf08663
zoom buttons under the photo, keyboard zoom and panning, PDF names on…
musa-kal Nov 13, 2026
c53e349
review photo keeps its height on phones; only the viewer frame stretches
musa-kal Nov 13, 2026
4add226
attachments after groups: migration renamed past main's newest, schem…
musa-kal Nov 14, 2026
6a8c884
receipts: trust a matching card line, no filler line for a cent, cap …
musa-kal Nov 14, 2026
dff9894
guard unloaded attachments, payment totals, in-flight uploads
musa-kal Nov 14, 2026
273e006
items make the total when no total line is read
musa-kal Nov 14, 2026
ff2335c
derive missing receipt totals and mark them unconfirmed
musa-kal Nov 14, 2026
5e244ab
anchor payment lines and sum split tender for derived totals
musa-kal Nov 14, 2026
ef62071
payment lines only confirm a missing total, never supply it
musa-kal Nov 14, 2026
7be207a
count cash only net of change; balance lines never paid
musa-kal Nov 14, 2026
766fc3f
derived scan total follows item edits; no filler line
musa-kal Nov 14, 2026
cbb3dae
note derived scan totals in ADR 21 fill rules
musa-kal Nov 14, 2026
83a49fc
receipts: linear regexes, a fixed sweep error and memoised photo URLs
musa-kal Nov 14, 2026
f82d0fb
receipts: previews of a picked file are typed as images or opaque bytes
musa-kal Nov 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Binary file added apps/web/e2e/fixtures/receipt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
120 changes: 120 additions & 0 deletions apps/web/e2e/receipts.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
66 changes: 66 additions & 0 deletions apps/web/ocr-assets.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const core = packageDir('tesseract.js-core')
const out: Record<string, string> = {
'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),
})
}
},
}
}
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:"
Expand Down
91 changes: 79 additions & 12 deletions apps/web/src/features/expenses/expense-detail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
49 changes: 7 additions & 42 deletions apps/web/src/features/expenses/expense-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -237,42 +233,11 @@ export function ExpenseDetail({ expenseId }: { expenseId: string }) {

<div className="flex flex-col gap-8">
<ItemsCard expense={e} me={me} nameOf={nameOf} />
{e.receipt_path && (
<section aria-labelledby="receipt-title" className="flex flex-col gap-3">
<h2 id="receipt-title" className="font-display text-2xl">
{t('expenses.detail.receipt')}
</h2>
{receipt.data ? (
isPdf ? (
<a
href={receipt.data}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-card border border-line bg-card p-4 font-semibold hover:border-line-strong"
>
<FileText aria-hidden className="size-6 text-mute" />
{t('expenses.receiptUpload.pdf')}
</a>
) : (
<a
href={receipt.data}
target="_blank"
rel="noreferrer"
aria-label={t('expenses.receiptUpload.open')}
className="block overflow-hidden rounded-card border border-line bg-sunken"
>
<img
src={receipt.data}
alt={t('expenses.receiptUpload.alt', { description: e.description })}
className="max-h-[28rem] w-full object-contain"
/>
</a>
)
) : (
<Skeleton className="h-56" />
)}
</section>
)}
<AttachmentGallery
expenseId={e.id}
receiptPath={e.receipt_path}
description={e.description}
/>
{e.notes && (
<section aria-labelledby="notes-title" className="flex flex-col gap-2">
<h2 id="notes-title" className="font-display text-2xl">
Expand Down
Loading
Loading