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:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
8b1e90abcc
commit
a004990041
@@ -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 caller’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user