fix(supplier-invoices): a credit note is never a payable, so it never waits for attest (#2289)

* fix(supplier-invoices): a credit note is never a payable, so it never waits for attest

A supplier credit note created with Kreditera was inserted at 'registered',
the attest entry state, while the detail page (rightly) offered no attest
for it. The worklist counted every 'registered' row, so Att göra showed
"1 leverantörsfaktura att attestera" that nobody could clear (support case
2026-09-04; 14 such rows on prod plus one MCP-approved credit note).

- One row builder (lib/supplier-invoices/credit-note.ts) for the dashboard
  route, the MCP executor and the v1 API: the credit note rests at
  'credited' from birth, the status the provider importers already use.
- CHECK supplier_invoices_credit_note_not_payable keeps every writer out of
  the payable states; migration backfills the stuck rows (one immutable
  reset-source row skipped, hence NOT VALID).
- The worklist attest count excludes credit notes explicitly.
- GET /api/supplier-invoices/[id] hydrates credited_original with a second
  scoped query: PostgREST cannot pick a direction for a self-referencing
  embed hint and returned the one-to-many side (an empty array), which the
  page rendered as "Krediterar: Ankomst #" with no number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

* test(supplier-invoices): type the GET route response in the credited_original tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

* fix(migration): NOT EXISTS instead of NOT IN for the reset-source exclusion

A NULL source_company_id in the subquery would make NOT IN never true and
silently skip the whole backfill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

* test(schema): raise the unresolved-payload ceiling by 3 for the credit-note row builder

The three credit-note creation paths now insert the row from one builder,
so the scanner sees three dynamic payloads instead of three literals. The
columns are the literal in lib/supplier-invoices/credit-note.ts, pinned by
its test; the resting status is additionally held by the DB CHECK.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

* test(pg): credit-note fixtures in the overdue-cron tests rest at credited

The two fixtures that seeded a credit note on 'registered'/'overdue' now
violate supplier_invoices_credit_note_not_payable. The cron test seeds the
credit note the way the routes create it since 20260904190000; the 20260607
backfill case asserts the scenario it repaired is now unreachable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

* test(pg): ledger-usage-stats seeds its credit note at credited

The fixture defaulted every row to 'registered', which the CHECK
supplier_invoices_credit_note_not_payable now refuses for a credit note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S62AGZwsMoBc8x8obBDVqE

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 09:43:00 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 1e1e1d5d17
commit e684606b23
17 changed files with 469 additions and 102 deletions
+1
View File
@@ -1583,5 +1583,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-04] Supplier-invoice list overflow (#2262) fixed per page, not in a shared list component: none exists (every list hand-writes the overflow-x-auto wrapper) and the three overflow reports had three different causes, so the column budget went into .claude/rules/design.md instead. Fakturadatum was dropped rather than Kvar or the action column (förfaller is the payer's date and the default order, the customer list has no invoice-date column either); the always-visible sort icon from #2091 was kept although it was the proximate regression.
[2026-09-04] Parties: the model reads a counterpart only on demand (picker, review list), never when the queue builds: a five-hundred-row queue would cost five hundred calls nobody asked for and a rebuild would repeat them; the reading is a 'model' fact and a search query, never a hard key. The review list ticks rows with exactly one active SCB match but writes nothing until a person approves: an exact legal name plus one active hit is high precision, auto-attaching would still be the system choosing. Exact legal-form names ("Visma Spcs AB", not "Visma") group keys and attach to existing parties: registered company names are unique in Sweden, so this is a key in all but form; the "name never merges" rule keeps applying to fuzzy and form-less names.
[2026-09-04] reset_fiscal_year's next_year_dependency no longer counts an opening-balance verifikat in the following year as reliance (migration 20260904163000 redefines fiscal_year_reset_snapshot; the block now fires only when the following year is locked, closed or has its own closing entry). Why: the old check (opening_balance_entry_id / opening_balances_set on the next period) fired for the dominant migration shape, import the first year with its own #IB and later backfill the year before it, so a backfilled year could never be reset (Aisen & Adison AB, 2026-09-03), while a next year WITHOUT an IB, whose balansrapport really rolls from this year's books, was allowed: the check was inverted relative to actual reliance. An IB in the next year is its own verifikat with its own underlag and survives the reset untouched; the one IB that IS derived from this year's books, the bokslut-generated one, is still refused via this year's closing_entry_id (year_end_state). The preview now returns next_period {name, has_opening_balances} and the dialog says the following year's IB stays as it is, instead of a blocker. Rejected alternative: stornoing the next year's IB inside the reset (it would destroy a correct migration boundary and re-create the #1022 dead end). Sibling fix in the same change: CreatePeriodDialog now derives the name from the dates the user types until the name is hand-edited, which is how a 2022-07-01..2023-12-31 year got saved as "Räkenskapsår 2027" (the seed suggestion is always the next forward year).
[2026-09-04] Supplier credit notes rest at 'credited' from creation (one row builder in lib/supplier-invoices/credit-note.ts for the dashboard route, the MCP executor and the v1 API) and a CHECK (supplier_invoices_credit_note_not_payable, 20260904190000) keeps every writer out of the payable states; the worklist attest count also excludes is_credit_note. Support case 2026-09-04: a credit note was inserted at 'registered' (the attest entry state) while the detail page rightly had no attest button for it, so Att göra showed "1 leverantörsfaktura att attestera" that nobody could clear; 14 such rows on prod plus one MCP-approved credit note, all backfilled by the migration (one immutable reset-source row skipped, hence NOT VALID). Chose the DB CHECK over fixing only the three insert literals because the invariant is "a credit note is never a payable", not "this literal says registered"; chose 'credited' over 'approved'+approved_at (would surface Markera betald and the Att betala tab) and over 'paid' (the customer side forbids paid credit notes by CHECK); the provider importers already used 'credited'. Also: the GET route now hydrates credited_original with a second scoped query because PostgREST cannot pick a direction for a self-referencing embed hint and returned the one-to-many side (an empty array), rendered as "Krediterar: Ankomst #" with no number.
[2026-09-04] Underlag attach on a folder-picked Fortnox export (Loftux, 50 of 50 files refused with UNDERLAG_REF_MISMATCH): the multipart filename is reduced to its basename at the route boundary (lib/documents/upload-file-name.ts), rather than teaching the voucher-ref parser to strip directories or adding a client-supplied file_name field. Chrome writes webkitRelativePath as the multipart filename for folder selections, so the attach check saw "2026/06/Leverantörsfakturor/A166_x.pdf" while the preview had resolved File.name "A166_x.pdf"; the two endpoints received the same file under two names and the guard compared them. Stripping inside the parser would turn a typed manual ref "2024/01/31" into voucher 31 (the manual box shares the parser), and a second client-supplied name is no more trustworthy than the first, so the boundary is the only level that fixes the class.
[2026-09-04] Connector-hop failures (timeout, error envelope, wire-contract mismatch) are transient in every sync path: the row keeps its status and the user message says no renewal is needed, same as AspspUnavailableError (#2202), and the cron now treats AspspUnavailableError the same way instead of parking it in 'error'. Why: on 2026-09-04 the Connect service answered a shape the client rejects and the cron flipped four canary companies to 'error' with SYNC_FAILED_MESSAGE, so users re-authorized consents that were fine. The Zod issues are logged (field paths) because a bare 'unexpected shape' left the failure undiagnosable. Rejected: a new 'degraded' connection status (one more state every filter and the probe would have to learn; the health probe already catches a dead session on the same run) and removing the canary companies from the env (hides the contract bug instead of exposing its field paths).
@@ -7,7 +7,7 @@ import {
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -23,7 +23,7 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
import { DELETE, GET } from '../route'
describe('DELETE /api/supplier-invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -219,3 +219,65 @@ describe('DELETE /api/supplier-invoices/[id]', () => {
expect(body.success).toBe(true)
})
})
describe('GET /api/supplier-invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
function getRequest(id: string) {
return GET(
createMockRequest(`/api/supplier-invoices/${id}`, { method: 'GET' }),
createMockRouteParams({ id }),
)
}
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
expect((await getRequest('si-1')).status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
expect((await getRequest('missing')).status).toBe(404)
})
it('hydrates the credited original of a credit note with a company-scoped lookup', async () => {
// credited_invoice_id is a self-reference, and a PostgREST embed on it
// resolved to the one-to-many side (an empty array), which the detail
// page rendered as "Krediterar: Ankomst #" with no number.
enqueue({
data: { id: 'cn-1', is_credit_note: true, credited_invoice_id: 'si-orig', items: [], payments: [] },
error: null,
})
enqueue({ data: { id: 'si-orig', supplier_invoice_number: '528285626420', arrival_number: 4 }, error: null })
const { status, body } = await parseJsonResponse<{ data: { credited_original: unknown } }>(await getRequest('cn-1'))
expect(status).toBe(200)
expect(body.data.credited_original).toEqual({
id: 'si-orig',
supplier_invoice_number: '528285626420',
arrival_number: 4,
})
const eqCalls = findCalls('supplier_invoices', 'eq')
expect(eqCalls).toContainEqual(['id', 'si-orig'])
expect(eqCalls.filter((c) => c[0] === 'company_id')).toHaveLength(2)
})
it('leaves credited_original null on an ordinary invoice without a second query', async () => {
enqueue({ data: { id: 'si-1', is_credit_note: false, credited_invoice_id: null, items: [], payments: [] }, error: null })
const { status, body } = await parseJsonResponse<{ data: { credited_original: unknown } }>(await getRequest('si-1'))
expect(status).toBe(200)
expect(body.data.credited_original).toBeNull()
expect(mockSupabase.from).toHaveBeenCalledTimes(1)
})
})
@@ -145,6 +145,16 @@ describe('POST /api/supplier-invoices/[id]/credit', () => {
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
// The credit note rests at 'credited' from birth: it is a reversal with
// nothing to attest or pay, so it must never enter the attest queue.
const insertedRow = findCall('supplier_invoices', 'insert')?.[0]
expect(insertedRow).toMatchObject({
status: 'credited',
is_credit_note: true,
credited_invoice_id: 'invoice-1',
remaining_amount: 0,
supplier_invoice_number: 'KREDIT-LF-001',
})
const insertArgs = findCall('supplier_invoice_items', 'insert')
const insertedItems = insertArgs?.[0] as Array<{ vat_rate: number }>
expect(insertedItems[0]?.vat_rate).toBe(0.25)
+9 -26
View File
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
import { buildSupplierCreditNoteRow } from '@/lib/supplier-invoices/credit-note'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -40,32 +41,14 @@ export const POST = withRouteContext(
const { data: creditNote, error: creditError } = await supabase
.from('supplier_invoices')
.insert({
user_id: user.id,
company_id: companyId,
supplier_id: original.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
invoice_date: new Date().toISOString().split('T')[0],
due_date: new Date().toISOString().split('T')[0],
status: 'registered',
currency: original.currency,
exchange_rate: original.exchange_rate,
vat_treatment: original.vat_treatment,
reverse_charge: original.reverse_charge,
subtotal: original.subtotal,
subtotal_sek: original.subtotal_sek,
vat_amount: original.vat_amount,
vat_amount_sek: original.vat_amount_sek,
total: original.total,
total_sek: original.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
// Copy the original's dimension bag so the reversal nets against the
// same dimension cells in reports (dimensions PR7).
default_dimensions: original.default_dimensions ?? {},
})
.insert(
buildSupplierCreditNoteRow(original, {
userId: user.id,
companyId,
arrivalNumber: arrivalNum,
date: new Date().toISOString().split('T')[0],
}),
)
.select()
.single()
+19 -4
View File
@@ -19,9 +19,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select(
'*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*), credited_original:supplier_invoices!credited_invoice_id(id, supplier_invoice_number, arrival_number)'
)
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -30,7 +28,24 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
return NextResponse.json({ data: invoice })
// The invoice a credit note reverses, fetched separately. credited_invoice_id
// is a self-reference, and PostgREST cannot pick a direction for a
// self-referencing embed from the column hint: the old
// `credited_original:supplier_invoices!credited_invoice_id(...)` resolved to
// the one-to-many side and came back as an empty array, which the detail
// page rendered as "Krediterar: Ankomst #" with no number.
let creditedOriginal: { id: string; supplier_invoice_number: string; arrival_number: number } | null = null
if (invoice.credited_invoice_id) {
const { data: original } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, arrival_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.maybeSingle()
creditedOriginal = original ?? null
}
return NextResponse.json({ data: { ...invoice, credited_original: creditedOriginal } })
},
)
@@ -26,6 +26,10 @@ import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
import {
SUPPLIER_CREDIT_NOTE_STATUS,
buildSupplierCreditNoteRow,
} from '@/lib/supplier-invoices/credit-note'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
@@ -250,7 +254,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`,
invoice_date: today,
due_date: today,
status: 'registered',
status: SUPPLIER_CREDIT_NOTE_STATUS,
currency: typed.currency,
exchange_rate: typed.exchange_rate,
subtotal: typed.subtotal,
@@ -281,32 +285,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Insert credit-note row.
const { data: creditNote, error: creditErr } = await ctx.supabase
.from('supplier_invoices')
.insert({
user_id: ctx.userId,
company_id: ctx.companyId!,
supplier_id: typed.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${typed.supplier_invoice_number}`,
invoice_date: today,
due_date: today,
status: 'registered',
currency: typed.currency,
exchange_rate: typed.exchange_rate,
vat_treatment: typed.vat_treatment,
reverse_charge: typed.reverse_charge,
subtotal: typed.subtotal,
subtotal_sek: typed.subtotal_sek,
vat_amount: typed.vat_amount,
vat_amount_sek: typed.vat_amount_sek,
total: typed.total,
total_sek: typed.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: typed.id,
// Copy the original's dimension bag so the credit-note verifikat nets
// against the same dimension cells in reports (dimensions PR7).
default_dimensions: typed.default_dimensions ?? {},
})
.insert(
buildSupplierCreditNoteRow(typed, {
userId: ctx.userId,
companyId: ctx.companyId!,
arrivalNumber: arrivalNum,
date: today,
}),
)
.select(SI_RESPONSE_COLUMNS)
.single()
+9 -25
View File
@@ -65,6 +65,7 @@ import {
canApproveSupplierInvoice,
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
import { buildSupplierCreditNoteRow } from '@/lib/supplier-invoices/credit-note'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { ISO_DATE_RE } from '@/lib/invariants/iso-date'
@@ -4934,31 +4935,14 @@ async function commitCreditSupplierInvoice(
const { data: creditNote, error: creditError } = await supabase
.from('supplier_invoices')
.insert({
user_id: userId,
company_id: companyId,
supplier_id: original.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
invoice_date: new Date().toISOString().split('T')[0],
due_date: new Date().toISOString().split('T')[0],
status: 'registered',
currency: original.currency,
exchange_rate: original.exchange_rate,
vat_treatment: original.vat_treatment,
reverse_charge: original.reverse_charge,
subtotal: original.subtotal,
subtotal_sek: original.subtotal_sek,
vat_amount: original.vat_amount,
vat_amount_sek: original.vat_amount_sek,
total: original.total,
total_sek: original.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
// Dimensions PR7: copy so the reversal nets against the same cells.
default_dimensions: original.default_dimensions ?? {},
})
.insert(
buildSupplierCreditNoteRow(original, {
userId,
companyId,
arrivalNumber: arrivalNum,
date: new Date().toISOString().split('T')[0],
}),
)
.select()
.single()
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { makeSupplierInvoice } from '@/tests/helpers'
import { SUPPLIER_CREDIT_NOTE_STATUS, buildSupplierCreditNoteRow } from '../credit-note'
const CTX = { userId: 'user-1', companyId: 'company-1', arrivalNumber: 7, date: '2026-09-04' }
describe('buildSupplierCreditNoteRow', () => {
it('rests the credit note at credited, never at the attest entry state', () => {
const row = buildSupplierCreditNoteRow(makeSupplierInvoice({ id: 'si-1' }), CTX)
expect(SUPPLIER_CREDIT_NOTE_STATUS).toBe('credited')
expect(row.status).toBe('credited')
expect(row.is_credit_note).toBe(true)
expect(row.credited_invoice_id).toBe('si-1')
expect(row.remaining_amount).toBe(0)
})
it('mirrors the original amounts and identity under the KREDIT- prefix', () => {
const original = makeSupplierInvoice({
id: 'si-1',
supplier_id: 'sup-1',
supplier_invoice_number: '528285626420',
currency: 'EUR',
exchange_rate: 11.2,
vat_treatment: 'standard_25',
reverse_charge: false,
subtotal: 1048,
subtotal_sek: 11737.6,
vat_amount: 262,
vat_amount_sek: 2934.4,
total: 1310,
total_sek: 14672,
})
const row = buildSupplierCreditNoteRow(
{ ...original, default_dimensions: { cost_center: 'cc-1' } },
CTX,
)
expect(row).toMatchObject({
user_id: 'user-1',
company_id: 'company-1',
supplier_id: 'sup-1',
arrival_number: 7,
supplier_invoice_number: 'KREDIT-528285626420',
invoice_date: '2026-09-04',
due_date: '2026-09-04',
currency: 'EUR',
exchange_rate: 11.2,
subtotal: 1048,
subtotal_sek: 11737.6,
vat_amount: 262,
vat_amount_sek: 2934.4,
total: 1310,
total_sek: 14672,
default_dimensions: { cost_center: 'cc-1' },
})
})
it('defaults a missing dimension bag to an empty object', () => {
const row = buildSupplierCreditNoteRow(makeSupplierInvoice({ id: 'si-1' }), CTX)
expect(row.default_dimensions).toEqual({})
})
})
+90
View File
@@ -0,0 +1,90 @@
/**
* Where a supplier credit note rests, from the moment it is created.
*
* A kreditfaktura from a supplier is the reversal of an invoice we already
* registered: Accounted creates it from the original with Kreditera, books
* the reversing verifikat in the same request and moves the original to
* 'credited'. Nothing is left to attest and nothing is left to pay, so the
* row must never enter the payable lifecycle (registered, approved, overdue,
* paid, partially_paid).
*
* It used to be inserted at that lifecycle's entry state, 'registered', which
* every consumer reads as "waiting for attest": the worklist counted it as
* "1 leverantörsfaktura att attestera" while the detail page (correctly)
* offered no attest for a credit note, so the item could never be cleared
* (support case 2026-09-04). 'credited' is the status the provider importers
* already give incoming credit notes, and the CHECK constraint
* supplier_invoices_credit_note_not_payable (20260904190000) holds every
* writer to it.
*/
export const SUPPLIER_CREDIT_NOTE_STATUS = 'credited' as const
/**
* The columns of the original that a credit note mirrors. Deliberately a
* structural type (not Pick<SupplierInvoice>): the v1 route reads the original
* through a narrower projection whose vat_treatment is a plain string.
*/
export interface SupplierCreditNoteSource {
id: string
supplier_id: string
supplier_invoice_number: string
currency: string
exchange_rate: number | null
vat_treatment: string
reverse_charge: boolean
subtotal: number
subtotal_sek: number | null
vat_amount: number
vat_amount_sek: number | null
total: number
total_sek: number | null
default_dimensions?: Record<string, string> | null
}
export interface SupplierCreditNoteContext {
userId: string
companyId: string
arrivalNumber: number
/** ISO yyyy-MM-dd: the credit note's invoice and due date. */
date: string
}
/**
* The supplier_invoices row for a credit note that reverses `original`.
*
* One builder for the three creation paths (dashboard route, MCP executor,
* v1 API) so the resting status, the zero remaining amount and the copied
* amounts cannot drift between them.
*/
export function buildSupplierCreditNoteRow(
original: SupplierCreditNoteSource,
ctx: SupplierCreditNoteContext,
) {
return {
user_id: ctx.userId,
company_id: ctx.companyId,
supplier_id: original.supplier_id,
arrival_number: ctx.arrivalNumber,
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
invoice_date: ctx.date,
due_date: ctx.date,
status: SUPPLIER_CREDIT_NOTE_STATUS,
currency: original.currency,
exchange_rate: original.exchange_rate,
vat_treatment: original.vat_treatment,
reverse_charge: original.reverse_charge,
subtotal: original.subtotal,
subtotal_sek: original.subtotal_sek,
vat_amount: original.vat_amount,
vat_amount_sek: original.vat_amount_sek,
total: original.total,
total_sek: original.total_sek,
// A credit note is never a payable: nothing remains to pay on it.
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: original.id,
// Copy the original's dimension bag so the reversal nets against the
// same dimension cells in reports (dimensions PR7).
default_dimensions: original.default_dimensions ?? {},
}
}
+13
View File
@@ -41,6 +41,19 @@ describe('countUnbookedTransactions', () => {
})
})
describe('countSupplierInvoicesAwaitingApproval', () => {
it('counts registered invoices but never credit notes', async () => {
// A credit note is a reversal with nothing to attest; the detail page
// offers no attest button for it, so counting one here made an item
// nobody could clear (support case 2026-09-04).
enqueue({ count: 1 })
await expect(countSupplierInvoicesAwaitingApproval(supabase, COMPANY)).resolves.toBe(1)
const eqCalls = findCalls('supplier_invoices', 'eq')
expect(eqCalls).toContainEqual(['status', 'registered'])
expect(eqCalls).toContainEqual(['is_credit_note', false])
})
})
describe('countUnbookedSkattekontoRows', () => {
it('counts only settled, unbooked, non-ignored skattekonto rows', async () => {
enqueue({ count: 3 })
+7
View File
@@ -184,6 +184,13 @@ export async function countSupplierInvoicesAwaitingApproval(
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'registered')
// A credit note is a reversal, never a payable: there is nothing to
// attest on it and the detail page offers no attest button, so counting
// one here made an item nobody could clear. The CHECK
// supplier_invoices_credit_note_not_payable keeps credit notes out of
// 'registered' since 20260904190000; the predicate states the rule where
// the count is defined.
.eq('is_credit_note', false)
if (error) return logAndZero('supplier_invoice_approval', companyId, error)
return count ?? 0
}
+2 -1
View File
@@ -59,7 +59,8 @@ export const WORKLIST_CATEGORIES = [
'suggested_match',
/**
* Supplier invoices awaiting approval ("attestera").
* Pending: supplier_invoices.status = 'registered'.
* Pending: supplier_invoices.status = 'registered' and not a credit note
* (a credit note is a reversal, never a payable).
* Done: status moves to approved/paid/credited/….
*/
'supplier_invoice_approval',
@@ -0,0 +1,42 @@
-- Supplier credit notes are reversals, not payables (support case 2026-09-04).
--
-- A kreditfaktura from a supplier is created from the original invoice with
-- Kreditera: the reversing verifikat is booked in the same request and the
-- original moves to 'credited'. Nothing is left to attest or to pay, yet the
-- credit note was inserted at the payable lifecycle's entry state,
-- 'registered', which every consumer reads as "waiting for attest". The
-- worklist counted it as a supplier invoice to attest while the detail page
-- (correctly) offered no attest for a credit note, so the item could never be
-- cleared. 'approved' was reachable the same way through the MCP approve
-- executor.
--
-- 1. Move every stuck credit note to 'credited', the status the provider
-- importers already give incoming credit notes. Archived migration-reset
-- sources are skipped: block_migration_reset_source_mutation() makes their
-- rows immutable and would abort the migration.
-- 2. Add a CHECK so no writer (dashboard, MCP executor, v1 API, importers)
-- can put a credit note into a payable state again. NOT VALID because the
-- archived reset-source row above cannot be repaired; the constraint is
-- enforced on every new insert and update regardless.
-- pg-test: covered-by tests/pg/supplier-credit-note-not-payable.pg.test.ts
UPDATE public.supplier_invoices
SET status = 'credited'
WHERE is_credit_note
AND status IN ('registered', 'approved', 'overdue')
AND NOT EXISTS (
SELECT 1 FROM public.company_migration_resets r
WHERE r.source_company_id = supplier_invoices.company_id
);
ALTER TABLE public.supplier_invoices
ADD CONSTRAINT supplier_invoices_credit_note_not_payable
CHECK (
NOT is_credit_note
OR status NOT IN ('registered', 'approved', 'overdue', 'paid', 'partially_paid')
) NOT VALID;
COMMENT ON CONSTRAINT supplier_invoices_credit_note_not_payable ON public.supplier_invoices IS
'A supplier credit note is a reversal, never a payable: it cannot use the attest or payment lifecycle states.';
NOTIFY pgrst, 'reload schema';
+3 -1
View File
@@ -143,7 +143,9 @@ async function insertSupplierWithInvoices(params: {
arrival++,
`SI-${arrival}`,
inv.invoiceDate,
inv.status ?? 'registered',
// A credit note rests at 'credited' (never a payable): the CHECK
// supplier_invoices_credit_note_not_payable refuses 'registered'.
inv.status ?? (inv.isCreditNote ? 'credited' : 'registered'),
inv.vatTreatment ?? 'standard_25',
inv.isCreditNote ?? false,
],
@@ -0,0 +1,99 @@
/**
* pg-real tests for 20260904190000_supplier_credit_note_not_payable.sql.
*
* A supplier credit note is a reversal, never a payable: it must not sit in
* the attest/payment lifecycle (registered, approved, overdue, paid,
* partially_paid). Ordinary supplier invoices keep the whole lifecycle.
*/
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
async function seedSupplierInvoicePair(): Promise<{
companyId: string
originalId: string
creditNoteId: string
insertCreditNote: (status: string) => Promise<unknown>
}> {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId })
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.suppliers (user_id, company_id, name)
VALUES ($1, $2, 'Hi3G Access AB') RETURNING id`,
[userId, companyId],
)
const supplierId = rows[0]!.id
const originalId = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, status, subtotal, vat_amount, total, remaining_amount)
VALUES ($1, $2, $3, $4, 4, '528285626420', '2026-08-03', '2026-08-17', 'credited',
1048, 262, 1310, 0)`,
[originalId, userId, companyId, supplierId],
)
let nextArrival = 5
const insertCreditNote = (status: string) => {
const id = randomUUID()
const arrival = nextArrival++
return getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, status, subtotal, vat_amount, total, remaining_amount,
is_credit_note, credited_invoice_id)
VALUES ($1, $2, $3, $4, $5, $6, '2026-09-04', '2026-09-04', $7,
1048, 262, 1310, 0, true, $8)`,
[id, userId, companyId, supplierId, arrival, `KREDIT-528285626420-${arrival}`, status, originalId],
).then(() => id)
}
const creditNoteId = (await insertCreditNote('credited')) as string
return { companyId, originalId, creditNoteId, insertCreditNote }
}
describe('supplier_invoices_credit_note_not_payable constraint', () => {
it.each(['registered', 'approved', 'overdue', 'paid', 'partially_paid'])(
'rejects moving a credit note to %s',
async (status) => {
const { creditNoteId } = await seedSupplierInvoicePair()
await expect(
getPool().query(`UPDATE public.supplier_invoices SET status = $1 WHERE id = $2`, [
status,
creditNoteId,
]),
).rejects.toMatchObject({ code: '23514' })
},
)
it('rejects inserting a credit note at the attest entry state', async () => {
// The state every creation path used before 20260904190000.
const { insertCreditNote } = await seedSupplierInvoicePair()
await expect(insertCreditNote('registered')).rejects.toMatchObject({ code: '23514' })
})
it('lets a credit note rest at credited and be soft-deleted to reversed', async () => {
const { creditNoteId } = await seedSupplierInvoicePair()
await expect(
getPool().query(`UPDATE public.supplier_invoices SET status = 'reversed' WHERE id = $1`, [
creditNoteId,
]),
).resolves.toMatchObject({ rowCount: 1 })
})
it('leaves ordinary supplier invoices on the payable lifecycle', async () => {
const { originalId } = await seedSupplierInvoicePair()
for (const status of ['registered', 'approved', 'overdue', 'partially_paid', 'paid']) {
await expect(
getPool().query(`UPDATE public.supplier_invoices SET status = $1 WHERE id = $2`, [
status,
originalId,
]),
).resolves.toMatchObject({ rowCount: 1 })
}
})
})
@@ -128,20 +128,21 @@ describe('update_overdue_supplier_invoices()', () => {
expect(await statusOf(id)).toBe('overdue')
})
it('never marks a credit note overdue (remaining 0, status registered)', async () => {
it('never marks a credit note overdue (remaining 0, status credited)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
// Mirrors how the credit routes create a credit note: registered, fully
// settled (remaining 0), due today (here: long past).
// Mirrors how the credit routes create a credit note since 20260904190000:
// resting at 'credited' (a reversal is never a payable), fully settled
// (remaining 0), due today (here: long past).
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'registered', dueDate: PAST, total: 1000, remaining: 0,
status: 'credited', dueDate: PAST, total: 1000, remaining: 0,
isCreditNote: true,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('registered')
expect(await statusOf(id)).toBe('credited')
})
it('never marks a fully-paid (remaining ~0) invoice overdue', async () => {
@@ -251,18 +252,21 @@ describe('overdue backfill (migration 20260607120000)', () => {
await getPool().query(SYMMETRIC_FUNCTION_SQL)
})
it('reverts a credit note wrongly stuck on overdue back to registered', async () => {
it('has nothing left to revert for credit notes: they can no longer reach overdue', async () => {
// The 20260607120000 backfill moved credit notes wrongly stuck on
// 'overdue' back to 'registered'. Since 20260904190000 a credit note
// cannot be in either state: supplier_invoices_credit_note_not_payable
// refuses the payable lifecycle for credit notes at the row level
// (tests/pg/supplier-credit-note-not-payable.pg.test.ts), so the
// scenario this backfill repaired is unreachable.
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true,
})
// Idempotent: re-running the migration only touches status='overdue' rows.
await getPool().query(MIGRATION_SQL)
expect(await statusOf(id)).toBe('registered')
await expect(
insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 0, isCreditNote: true,
}),
).rejects.toMatchObject({ code: '23514', constraint: 'supplier_invoices_credit_note_not_payable' })
})
it('marks a fully-paid invoice stuck on overdue as paid (and stamps paid_at)', async () => {
+8 -1
View File
@@ -167,7 +167,14 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
// (parties phase 1, #2162/#2168/#2169) at 395: 397.
// 2026-09-04: +2 recurring lines (#2044, see the 2026-08-30 recurring payroll
// lines note above); merged with main (#2141/#2164/#2170/#2192) at 397: 399.
const UNRESOLVED_CEILING = 399
// 2026-09-04: +3 supplier credit notes (#2289): the dashboard credit route,
// commitCreditSupplierInvoice (MCP) and the v1 credit route insert the
// credit-note row from one builder, buildSupplierCreditNoteRow() in
// lib/supplier-invoices/credit-note.ts, so the resting status is decided in
// one place (and held by CHECK supplier_invoices_credit_note_not_payable);
// its columns are the object literal in that file, pinned by
// credit-note.test.ts. Merged with main (#2288) at 399: 402.
const UNRESOLVED_CEILING = 402
/**
* Floor on statically resolved column references. Guards the guard: if a change