fix(documents): name the journal_entries/fiscal_periods relationship so supplier-invoice underlag can anchor (#2109)

Prod has three foreign keys between journal_entries and fiscal_periods, so
PostgREST answers PGRST201 to any embed of that pair that does not name the
relationship. pickAnchorEntry() destructured only data, so the error was
dropped and the helper returned null on every call since it shipped on
2026-07-27: supplier-invoice underlag has never once anchored in production.
Users see "Underlag saknas" on a verifikat that plainly shows the invoice PDF.

Names the constraint, matching the already-merged sibling fix in
lib/transactions/inbox-underlag.ts (6a40b3c0e), and handles the error instead
of dropping it.

Adds scripts/checks/ambiguous-embed.mjs to the ratchet guard, because neither
test layer can see this class: a mocked Supabase client never resolves a
relationship, and pg-real bypasses PostgREST entirely. The check derives the
ambiguous table pairs by parsing supabase/migrations, so a migration adding a
second foreign key between two tables arms the guard on the same commit; the
derived list reproduces prod's pg_constraint output exactly. It accepts both
PostgREST hint forms (constraint name and FK column name, both in use here) and
parses aliased embeds, which is the shape the real bug took.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 14:44:54 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 43341aa55c
commit 4ec2ff4b4d
6 changed files with 761 additions and 3 deletions
@@ -254,6 +254,73 @@ describe('anchorSupplierInvoiceDocument', () => {
).resolves.toBeNull()
})
it('names the foreign key in the fiscal_periods embed', async () => {
// fiscal_periods points back at journal_entries twice (closing_entry_id,
// opening_balance_entry_id), so the bare embed is ambiguous and PostgREST
// answers PGRST201. The bare form shipped 2026-07-27 and this helper
// anchored nothing in production until 2026-09-01. A mocked client resolves
// no relationship, so asserting the string is the only thing a unit test
// can do here; scripts/checks/ambiguous-embed.mjs is the repo-wide guard.
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{ data: [{ id: 'je-reg', status: 'posted', fiscal_period: openPeriod }] },
{ data: [{ id: 'doc-1' }] },
])
await anchorSupplierInvoiceDocument(supabase as unknown as SupabaseClient, 'company-1', 'si-1')
expect(findCall('journal_entries', 'select')?.[0]).toContain(
'fiscal_periods!journal_entries_fiscal_period_id_fkey',
)
})
it('logs the reason when the period lock state cannot be read, and anchors nothing', async () => {
// Failing closed is right: never anchor on a lock state we could not read.
// Failing SILENTLY is what let the PGRST201 above sit dead for five weeks,
// with the caller's "no verifikat can anchor it" warning as the only
// signal, naming a cause that had nothing to do with it.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: {
id: 'si-1',
document_id: 'doc-1',
registration_journal_entry_id: 'je-reg',
payment_journal_entry_id: null,
},
},
{ data: { id: 'doc-1', journal_entry_id: null, is_current_version: true } },
{ data: [] },
{
error: {
message:
"Could not embed because more than one relationship was found for 'journal_entries' and 'fiscal_periods'",
},
},
])
await expect(
anchorSupplierInvoiceDocument(supabase as unknown as SupabaseClient, 'company-1', 'si-1'),
).resolves.toBeNull()
// Stopped at the failed read: no UPDATE was attempted.
expect(supabase.from).toHaveBeenCalledTimes(4)
const logged = consoleError.mock.calls.flat().join(' ')
expect(logged).toContain('failed to resolve period lock state for supplier invoice anchoring')
expect(logged).toContain('more than one relationship')
consoleError.mockRestore()
})
it('reports failure as null instead of throwing at the caller', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
@@ -176,12 +176,31 @@ async function pickAnchorEntry(
}
if (candidates.length === 0) return null
const { data: entries } = await supabase
const { data: entries, error } = await supabase
.from('journal_entries')
.select('id, status, fiscal_period:fiscal_periods(is_closed, locked_at)')
// fiscal_periods also points back at journal_entries (closing_entry_id,
// opening_balance_entry_id), so PostgREST refuses the bare embed as
// ambiguous; name the FK explicitly.
.select(
'id, status, fiscal_period:fiscal_periods!journal_entries_fiscal_period_id_fkey(is_closed, locked_at)',
)
.eq('company_id', companyId)
.in('id', candidates)
if (error) {
// Fail closed: never anchor on a lock state we could not read. But say so.
// The bare embed above returned PGRST201 on every call from 2026-07-27
// onwards and the result was dropped on the floor, so the caller's "no
// verifikat can anchor it" warning was the only signal, and it named the
// wrong cause.
log.error('failed to resolve period lock state for supplier invoice anchoring', {
companyId,
supplierInvoiceId,
reason: error.message,
})
return null
}
type EntryRow = {
id: string
status: string