feat(inbox): the purchases that are still missing their receipt (#1517)

The page lists documents, so a purchase with no document at all could not
appear on it. That is precisely the gap the receipt hunt exists to close,
and the half a user can do something about: fetch the invoice from the
supplier's portal, or ask whoever made the purchase.

GET /purchases supplies it, read-only, with the portal link attached when
the directory knows where that supplier keeps its invoices. Salary and tax
get no link: they have no invoice to fetch, and a link there implies
somewhere to go.

The predicate moves into lib/transactions/purchases-without-underlag.ts,
and it is not the hunt's filter copied across. `journal_entry_id IS NULL`
is not the same as "not booked": bulk-booking many transactions onto one
verifikat records it in transaction_voucher_links, and a payment split
across invoices records it in the payment tables, and both leave that
column null. The hunt tolerates the false candidate because the worst case
is a proposal nobody accepts. A list shown to a person does not: those
rows would sit under "saknar underlag" forever, already booked, with
nothing the user could do to clear them.

So the column filter stays as the cheap indexed first pass and
isTransactionBooked settles it afterwards. That predicate is canonical and
nothing here re-implements it.

The hunt is deliberately not rewired in this change: its query is
identical apart from that check, but moving a nightly cron onto new code
belongs in its own PR. The thresholds are kept equal to the hunt's so the
two cannot drift meanwhile.

Removing the booked filter fails five of the seven predicate tests.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-11 12:09:42 +02:00
committed by GitHub
parent 8b1e90abcc
commit a004990041
4 changed files with 380 additions and 0 deletions
@@ -0,0 +1,113 @@
/**
* GET /purchases — the purchases the page could never show.
*
* The list has always been documents, so a purchase with no document at all
* could not appear on it. This route supplies that half, and attaches the
* portal link when we know where the supplier keeps its invoices.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
const fetchPurchases = vi.fn()
vi.mock('@/lib/transactions/purchases-without-underlag', () => ({
fetchPurchasesWithoutUnderlag: (...a: unknown[]) => fetchPurchases(...a),
}))
const route = invoiceInboxExtension.apiRoutes!.find(
(r) => r.method === 'GET' && r.path === '/purchases',
)!
function buildCtx(): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
supabase: {} as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
} as unknown as ExtensionContext
}
const req = () => createMockRequest('/purchases', { method: 'GET' })
function purchase(over: Record<string, unknown> = {}) {
return {
id: 'tx-1',
company_id: 'company-1',
date: '2026-07-23',
description: 'OPENAI CHATGPT SUBSCR',
merchant_name: null,
amount: -229,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
journal_entry_id: null,
...over,
}
}
beforeEach(() => {
vi.clearAllMocks()
fetchPurchases.mockResolvedValue([])
})
describe('GET /purchases', () => {
it('returns 401 without a context', async () => {
expect((await route.handler(req())).status).toBe(401)
})
it('returns the purchases with a count', async () => {
fetchPurchases.mockResolvedValue([purchase(), purchase({ id: 'tx-2' })])
const { body } = await parseJsonResponse<{ data: { count: number; purchases: unknown[] } }>(
await route.handler(req(), buildCtx()),
)
expect(body.data.count).toBe(2)
expect(body.data.purchases).toHaveLength(2)
})
it('says where the invoice lives when the supplier keeps it behind a login', async () => {
fetchPurchases.mockResolvedValue([purchase()])
const { body } = await parseJsonResponse<{
data: { purchases: { portal: { vendor: string; url: string } | null }[] }
}>(await route.handler(req(), buildCtx()))
expect(body.data.purchases[0].portal?.vendor).toBe('OpenAI')
expect(body.data.purchases[0].portal?.url).toContain('https://')
})
it('offers no portal for a payment that has no invoice', async () => {
// A salary run has nothing to fetch. A link there implies somewhere to go.
fetchPurchases.mockResolvedValue([
purchase({ description: 'Lön Juli Jakob Överföring via internet', merchant_name: null }),
])
const { body } = await parseJsonResponse<{ data: { purchases: { portal: unknown }[] } }>(
await route.handler(req(), buildCtx()),
)
expect(body.data.purchases[0].portal).toBeNull()
})
it('offers no portal for a supplier the directory does not know', async () => {
fetchPurchases.mockResolvedValue([
purchase({ description: 'ALVIKS KOETT OCH FISK K3667', merchant_name: 'Alviks Kött och Fisk' }),
])
const { body } = await parseJsonResponse<{ data: { purchases: { portal: unknown }[] } }>(
await route.handler(req(), buildCtx()),
)
expect(body.data.purchases[0].portal).toBeNull()
})
it('scopes the lookup to the callers company', async () => {
await route.handler(req(), buildCtx())
expect(fetchPurchases).toHaveBeenCalledWith(expect.anything(), 'company-1')
})
it('reports a failure as a failure', async () => {
fetchPurchases.mockRejectedValue(new Error('boom'))
const res = await route.handler(req(), buildCtx())
expect(res.status).toBe(500)
})
})
+50
View File
@@ -60,6 +60,8 @@ import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entrie
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import type { Transaction, EntityType } from '@/types'
import { createLogger } from '@/lib/logger'
import { fetchPurchasesWithoutUnderlag } from '@/lib/transactions/purchases-without-underlag'
import { lookupPortal } from '@/lib/receipt-hunt/portal-directory'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { simpleParser } from 'mailparser'
@@ -2450,6 +2452,54 @@ export const invoiceInboxExtension: Extension = {
}
},
},
// ── Purchases still missing their underlag ────────────────────
//
// The page has always listed documents, so a purchase with no document at
// all could not appear on it. That is exactly the gap the receipt hunt
// exists to close, and the half a user can act on: fetch the invoice from
// the supplier's portal, or ask whoever made the purchase.
//
// Read-only. The predicate is shared with the hunt so the page and the
// nightly run cannot disagree about what "missing its receipt" means.
{
method: 'GET',
path: '/purchases',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
try {
const purchases = await fetchPurchasesWithoutUnderlag(ctx.supabase, ctx.companyId)
return NextResponse.json({
data: {
count: purchases.length,
purchases: purchases.map((p) => {
// Where the invoice lives, when the supplier does not send one.
// lookupPortal answers null for salary and tax, which have no
// invoice to fetch: a link there would be worse than silence.
const portal = lookupPortal(p.merchant_name || p.description)
return {
id: p.id,
date: p.date,
description: p.description,
merchant_name: p.merchant_name,
amount: p.amount,
currency: p.currency,
amount_sek: p.amount_sek,
portal: portal ? { vendor: portal.vendor, url: portal.url, note: portal.note ?? null } : null,
}
}),
},
})
} catch (err) {
ctx.log.error('purchases lookup failed', {
error: err instanceof Error ? err.message : String(err),
})
return NextResponse.json({ error: 'Kunde inte hämta köpen' }, { status: 500 })
}
},
},
],
}
@@ -0,0 +1,110 @@
/**
* What counts as a purchase still missing its underlag.
*
* The interesting cases are the ones the column filter cannot see. A bank
* transaction can be booked three ways, and only one of them sets
* `journal_entry_id`: bulk-booking many transactions onto one verifikat records
* it in transaction_voucher_links, and a payment split across invoices records
* it in the payment tables. Both leave the column null.
*
* The receipt hunt tolerates those false candidates because the worst case is a
* proposal nobody accepts. This list is shown to a person, where the same rows
* would sit under "saknar underlag" forever, already booked, with nothing the
* user could do to clear them.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchPurchasesWithoutUnderlag } from '../purchases-without-underlag'
const rows = vi.fn()
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: async () => rows(),
}))
function tx(over: Record<string, unknown> = {}) {
return {
id: 'tx-1',
company_id: 'company-1',
date: '2026-08-04',
description: 'Elgiganten',
merchant_name: 'Elgiganten',
amount: -21639,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
journal_entry_id: null,
...over,
}
}
/** Supabase double whose link tables answer per table name. */
function db(links: { voucher?: string[]; invoice?: string[]; supplier?: string[] } = {}) {
const calls: string[] = []
return {
calls,
from(table: string) {
calls.push(table)
const ids =
table === 'transaction_voucher_links'
? links.voucher
: table === 'invoice_payments'
? links.invoice
: links.supplier
const data = (ids ?? []).map((id) => ({ transaction_id: id }))
const chain = { select: () => chain, in: async () => ({ data }) }
return chain
},
} as never
}
beforeEach(() => vi.clearAllMocks())
describe('fetchPurchasesWithoutUnderlag', () => {
it('returns purchases nothing has booked', async () => {
rows.mockReturnValue([tx()])
const out = await fetchPurchasesWithoutUnderlag(db(), 'company-1')
expect(out).toHaveLength(1)
expect(out[0].id).toBe('tx-1')
})
it('skips the link-table lookups when there is nothing to check', async () => {
rows.mockReturnValue([])
const supabase = db()
const out = await fetchPurchasesWithoutUnderlag(supabase, 'company-1')
expect(out).toEqual([])
expect((supabase as unknown as { calls: string[] }).calls).toEqual([])
})
it('excludes a transaction bulk-booked through a voucher link', async () => {
// Many transactions, one verifikat: journal_entry_id stays null on every
// one of them, so the column filter lets them all through.
rows.mockReturnValue([tx({ id: 'tx-bulk' })])
const out = await fetchPurchasesWithoutUnderlag(db({ voucher: ['tx-bulk'] }), 'company-1')
expect(out).toEqual([])
})
it('excludes a transaction that paid a customer invoice', async () => {
rows.mockReturnValue([tx({ id: 'tx-pay' })])
const out = await fetchPurchasesWithoutUnderlag(db({ invoice: ['tx-pay'] }), 'company-1')
expect(out).toEqual([])
})
it('excludes a transaction that paid a supplier invoice', async () => {
rows.mockReturnValue([tx({ id: 'tx-sup' })])
const out = await fetchPurchasesWithoutUnderlag(db({ supplier: ['tx-sup'] }), 'company-1')
expect(out).toEqual([])
})
it('keeps the unbooked ones when only some of a batch are booked', async () => {
rows.mockReturnValue([tx({ id: 'a' }), tx({ id: 'b' }), tx({ id: 'c' })])
const out = await fetchPurchasesWithoutUnderlag(db({ voucher: ['b'] }), 'company-1')
expect(out.map((p) => p.id)).toEqual(['a', 'c'])
})
it('excludes a row that already carries a journal entry', async () => {
// Belt and braces: the query filters this out, but the predicate is what
// the page trusts and it must agree.
rows.mockReturnValue([tx({ journal_entry_id: 'je-1' })])
const out = await fetchPurchasesWithoutUnderlag(db(), 'company-1')
expect(out).toEqual([])
})
})
@@ -0,0 +1,107 @@
/**
* Purchases that have no underlag.
*
* The receipt hunt has asked this question since it was built, privately, in
* `fetchCandidates`. The Underlag page needs the same answer to show a purchase
* that is still missing its paper, so the query lives here now.
*
* The hunt is NOT yet migrated onto this: its query is byte-identical apart
* from the booked check below, but rewiring a nightly cron belongs in its own
* change with its own tests. The thresholds are deliberately kept equal to the
* hunt's so the two cannot drift in the meantime, and the hunt's constants are
* the source of that equality.
*
* One thing differs between the two callers, and it matters. The hunt filters on
* `journal_entry_id IS NULL` alone, which is not the same as "not booked": a
* bulk-booked transaction (many transactions, one verifikat, joined through
* transaction_voucher_links) and a payment split across several invoices both
* leave that column null. The hunt tolerates the false candidate, since the
* worst case is a proposal nobody accepts. A list shown to a person does not:
* those rows would sit under "saknar underlag" forever, already booked, with
* nothing the user could do to clear them.
*
* So the column filter stays as the cheap first pass the database can index,
* and `isTransactionBooked` settles it afterwards with the two link tables it
* needs. That predicate is canonical; nothing here re-implements it.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { isTransactionBooked } from '@/lib/transactions/is-booked'
/**
* Kept equal to the receipt hunt's own thresholds (MIN_AMOUNT_SEK,
* LOOKBACK_MONTHS in lib/receipt-hunt/hunt.ts). A page that showed purchases
* the hunt never looks for would offer a row it can never resolve on its own.
*/
export const MIN_PURCHASE_AMOUNT_SEK = 100
export const PURCHASE_LOOKBACK_MONTHS = 12
export interface PurchaseWithoutUnderlag {
id: string
company_id: string
date: string
description: string | null
merchant_name: string | null
amount: number
currency: string | null
amount_sek: number | null
exchange_rate: number | null
journal_entry_id: string | null
}
const COLUMNS =
'id, company_id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate, journal_entry_id'
/**
* Outflows with no document, not ignored, not privately flagged, and not booked
* by any of the three routes a transaction can be booked through.
*
* Ordered newest first: a receipt for last week is findable, one from a year ago
* usually is not.
*/
export async function fetchPurchasesWithoutUnderlag(
supabase: SupabaseClient,
companyId: string,
options?: { lookbackMonths?: number; minAmountSek?: number },
): Promise<PurchaseWithoutUnderlag[]> {
const lookback = options?.lookbackMonths ?? PURCHASE_LOOKBACK_MONTHS
const minAmount = options?.minAmountSek ?? MIN_PURCHASE_AMOUNT_SEK
const since = new Date()
since.setMonth(since.getMonth() - lookback)
const sinceDate = since.toISOString().slice(0, 10)
const rows = await fetchAllRows<PurchaseWithoutUnderlag>((range) =>
supabase
.from('transactions')
.select(COLUMNS)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.is('document_id', null)
.eq('is_ignored', false)
// is_business IS DISTINCT FROM false: NULL is untriaged and true is
// "business, not yet booked". Only an explicit false means the user
// called it private, and a private purchase needs no underlag.
.not('is_business', 'is', false)
// Outflows only, and amount <= -MIN covers the floor in one filter.
.lte('amount', -minAmount)
.gte('date', sinceDate)
.order('date', { ascending: false })
.range(range.from, range.to),
)
if (rows.length === 0) return []
// The column filter above cannot see these two. Fetched once for the whole
// page rather than per row.
const ids = rows.map((r) => r.id)
const [{ data: voucherLinks }, { data: invoicePayments }, { data: supplierPayments }] =
await Promise.all([
supabase.from('transaction_voucher_links').select('transaction_id').in('transaction_id', ids),
supabase.from('invoice_payments').select('transaction_id').in('transaction_id', ids),
supabase.from('supplier_invoice_payments').select('transaction_id').in('transaction_id', ids),
])
const payments = [...(invoicePayments ?? []), ...(supplierPayments ?? [])]
return rows.filter((tx) => !isTransactionBooked(tx, payments, voucherLinks ?? []))
}