Files
accounted/tests/pg/complete-invoice-rows-rpc.pg.test.ts
Jakob Wennberg 39d409d257 fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them (#2313) (#2340)
* fix(invoices): write migrated invoice rows through one locking RPC so two writers cannot double them

The row-completion pass (#2291) and the migration wizard both wrote
invoice_items for migrated sales invoices with check-then-insert across
separate statements and nothing serializing them per invoice; the pass
also wrote the header VAT split in a third statement, so "rows landed,
header did not" was reachable and never revisited.

Adds complete_invoice_rows (SECURITY DEFINER, FOR UPDATE on the invoice
scoped to the company, inserts only when the invoice still has no rows,
optional header split in the same transaction, returns wrote) and routes
both writers through it: the pass one call per invoice (wrote = false is
skipped, not completed), the wizard one call per invoice in small
concurrent groups. Unknown row keys and partial headers are refused
rather than dropped. Grants: revoked from PUBLIC and anon, kept for
authenticated (membership gate in the body) and service_role.

pg test proves the invariant (first call writes, second returns wrote =
false with rows and header unchanged), the rollback of rows on a failing
header, every refusal, the grants and two-connection serialization.

Closes #2313

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

* fix(invoices): complete_invoice_rows requires the row's tax facts instead of defaulting vat_rate to 25

Review finding on #2340: COALESCE(r.vat_rate, 25) let a row without a
rate land with a fabricated 25 % (ML 17 kap 24 § p.9). Both writers
always send vat_rate, line_total, vat_amount and description, so the
defaults were never needed and only hid a bug. The RPC now refuses a
row missing any of the four (absent or JSON null) with
MISSING_REQUIRED naming the column; sort_order, quantity, unit and
line_type keep their table defaults since none states a tax fact.
The rate's value is deliberately not restricted to the Swedish set:
0 (omvänd skattskyldighet, export) and foreign rates (OSS) are
legitimate on a migrated row, and the pg test pins both as accepted.

Migration edited in place: unshipped, preview branches only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:55:42 +02:00

363 lines
15 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import type { PoolClient } from 'pg'
import { getPool, getClient, runAsServiceRole, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
// pg-real coverage for 20260906135730_complete_invoice_rows_rpc:
// complete_invoice_rows writes a migrated invoice's rows (and its optional
// header VAT split) at most once. It locks the invoice, inserts only when the
// invoice still has no rows, applies the header in the same transaction,
// refuses non-members and foreign invoices, and serializes concurrent
// writers so the second one finds the first one's rows (#2313).
type RpcResult = {
ok: boolean
code?: string
wrote?: boolean
rows?: number
header_updated?: boolean
details?: Record<string, unknown>
}
const SIGNATURE = 'public.complete_invoice_rows(uuid,uuid,jsonb,jsonb)'
/** Two rows the way mapSalesInvoiceLine emits them: 1 000 kr net, 25 %. */
const ROWS = [
{ sort_order: 1, description: 'Konsulttid', quantity: 8, unit: 'h', unit_price: 100, line_total: 800, vat_rate: 25, vat_amount: 200, line_type: 'product' },
{ sort_order: 2, description: 'Resa', quantity: 1, unit: 'st', unit_price: 200, line_total: 200, vat_rate: 25, vat_amount: 50, line_type: 'product' },
]
/** The header split the detail form established for ROWS. */
const HEADER = {
subtotal: 1000,
subtotal_sek: 1000,
vat_amount: 250,
vat_amount_sek: 250,
vat_rate: 25,
vat_treatment: 'standard_25',
}
/**
* A migrated invoice the way the pre-#1745 import left it: total right, 25 %
* label beside 0 kr VAT and subtotal = total, and no rows.
*/
async function insertInvoice(companyId: string, userId: string): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Kund AB', 'swedish_business')`,
[customerId, userId, companyId],
)
const id = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number, document_type,
invoice_date, due_date, currency, subtotal, subtotal_sek, vat_amount, vat_amount_sek,
total, total_sek, vat_treatment, vat_rate, status)
VALUES ($1, $2, $3, $4, $5, 'invoice',
'2026-03-14', '2026-04-13', 'SEK', 1250, 1250, 0, 0,
1250, 1250, 'standard_25', 25, 'sent')`,
[id, userId, companyId, customerId, `1001-${id.slice(0, 8)}`],
)
return id
}
async function callRpc(
client: PoolClient,
companyId: string,
invoiceId: string,
rows: unknown,
header: unknown = null,
): Promise<RpcResult> {
const { rows: out } = await client.query<{ r: RpcResult }>(
`SELECT public.complete_invoice_rows($1, $2, $3::jsonb, $4::jsonb) AS r`,
[companyId, invoiceId, JSON.stringify(rows), header === null ? null : JSON.stringify(header)],
)
return out[0].r
}
async function beginAsUser(client: PoolClient, userId: string): Promise<void> {
await client.query('BEGIN')
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'authenticated' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query('SET LOCAL ROLE authenticated')
}
/** Like withUserContext but COMMITs, so a later session can observe the result. */
async function asUser<T>(userId: string, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await getClient()
try {
await beginAsUser(client, userId)
const result = await fn(client)
await client.query('COMMIT')
return result
} catch (err) {
await client.query('ROLLBACK').catch(() => {})
throw err
} finally {
client.release()
}
}
async function storedRows(invoiceId: string) {
const { rows } = await getPool().query<{
description: string
sort_order: number
quantity: string
unit: string
line_total: string
vat_rate: string
line_type: string
}>(
`SELECT description, sort_order, quantity::text, unit, line_total::text, vat_rate::text, line_type
FROM public.invoice_items WHERE invoice_id = $1 ORDER BY sort_order, description`,
[invoiceId],
)
return rows
}
async function storedHeader(invoiceId: string) {
const { rows } = await getPool().query<{
subtotal: string
subtotal_sek: string
vat_amount: string
vat_amount_sek: string
vat_rate: string
vat_treatment: string
total: string
}>(
`SELECT subtotal::text, subtotal_sek::text, vat_amount::text, vat_amount_sek::text,
vat_rate::text, vat_treatment, total::text
FROM public.invoices WHERE id = $1`,
[invoiceId],
)
const h = rows[0]!
return {
subtotal: Number(h.subtotal),
subtotal_sek: Number(h.subtotal_sek),
vat_amount: Number(h.vat_amount),
vat_amount_sek: Number(h.vat_amount_sek),
vat_rate: Number(h.vat_rate),
vat_treatment: h.vat_treatment,
total: Number(h.total),
}
}
describe('complete_invoice_rows', () => {
it('writes the rows and the header split together, and only once', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const first = await asUser(userId, (client) => callRpc(client, companyId, invoiceId, ROWS, HEADER))
expect(first).toEqual({ ok: true, wrote: true, rows: 2, header_updated: true })
const rows = await storedRows(invoiceId)
expect(rows.map((r) => r.description)).toEqual(['Konsulttid', 'Resa'])
expect(rows[0]).toMatchObject({ sort_order: 1, unit: 'h', line_type: 'product' })
expect(Number(rows[0].quantity)).toBe(8)
expect(Number(rows[0].line_total)).toBe(800)
expect(Number(rows[0].vat_rate)).toBe(25)
const header = await storedHeader(invoiceId)
expect(header).toEqual({ ...HEADER, total: 1250 })
// Same rows again, or different ones: nothing is appended and the header
// is not touched, whichever run gets there second.
const again = await asUser(userId, (client) => callRpc(client, companyId, invoiceId, ROWS, HEADER))
expect(again).toEqual({ ok: true, wrote: false, rows: 0, header_updated: false })
const other = await asUser(userId, (client) =>
callRpc(
client, companyId, invoiceId,
[{ ...ROWS[0], description: 'Annat', line_total: 5, vat_amount: 1.25 }],
{ ...HEADER, subtotal: 5 },
),
)
expect(other).toEqual({ ok: true, wrote: false, rows: 0, header_updated: false })
expect((await storedRows(invoiceId)).map((r) => r.description)).toEqual(['Konsulttid', 'Resa'])
expect(await storedHeader(invoiceId)).toEqual({ ...HEADER, total: 1250 })
})
it('writes the rows without a header when none is given (the wizard path); only the non-tax columns take table defaults', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const before = await storedHeader(invoiceId)
const r = await asUser(userId, (client) =>
callRpc(client, companyId, invoiceId, [{ description: 'Bara text', line_total: 0, vat_rate: 0, vat_amount: 0 }]),
)
expect(r).toEqual({ ok: true, wrote: true, rows: 1, header_updated: false })
const rows = await storedRows(invoiceId)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ description: 'Bara text', sort_order: 0, unit: 'st', line_type: 'product' })
expect(Number(rows[0].quantity)).toBe(1)
expect(Number(rows[0].line_total)).toBe(0)
expect(Number(rows[0].vat_rate)).toBe(0)
expect(await storedHeader(invoiceId)).toEqual(before)
})
it('refuses a row that omits a tax fact instead of defaulting it (no fabricated 25 %)', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
await withUserContext(userId, async (client) => {
for (const key of ['vat_rate', 'line_total', 'vat_amount', 'description'] as const) {
const { [key]: _omitted, ...without } = ROWS[0]
void _omitted
expect(await callRpc(client, companyId, invoiceId, [without])).toEqual({
ok: false, code: 'MISSING_REQUIRED', details: { column: key },
})
// JSON null is "absent" too: it must not fall through to a default.
expect(await callRpc(client, companyId, invoiceId, [{ ...ROWS[0], [key]: null }])).toEqual({
ok: false, code: 'MISSING_REQUIRED', details: { column: key },
})
}
// One bad row refuses the whole set: an invoice's rows land together.
expect(await callRpc(client, companyId, invoiceId, [ROWS[0], { ...ROWS[1], vat_rate: undefined }])).toEqual({
ok: false, code: 'MISSING_REQUIRED', details: { column: 'vat_rate' },
})
})
expect(await storedRows(invoiceId)).toHaveLength(0)
})
it('accepts a stated 0 % (omvänd skattskyldighet, export) and a foreign rate (OSS): the value is not restricted', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const r = await asUser(userId, (client) =>
callRpc(client, companyId, invoiceId, [
{ ...ROWS[0], description: 'Konsulttid DE (reverse charge)', vat_rate: 0, vat_amount: 0 },
{ ...ROWS[1], description: 'Vara DE (OSS)', vat_rate: 19, vat_amount: 38 },
]),
)
expect(r).toEqual({ ok: true, wrote: true, rows: 2, header_updated: false })
const rows = await storedRows(invoiceId)
expect(rows.map((row) => Number(row.vat_rate))).toEqual([0, 19])
})
it('rolls the rows back when the header update fails: header and rows land together or not at all', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const before = await storedHeader(invoiceId)
await expect(
asUser(userId, (client) => callRpc(client, companyId, invoiceId, ROWS, { ...HEADER, vat_rate: 'tjugofem' })),
).rejects.toThrow(/numeric/)
expect(await storedRows(invoiceId)).toHaveLength(0)
expect(await storedHeader(invoiceId)).toEqual(before)
})
it('refuses a payload it cannot store honestly, before locking anything', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
await withUserContext(userId, async (client) => {
expect(await callRpc(client, companyId, invoiceId, [])).toEqual({ ok: false, code: 'NO_ROWS' })
expect(await callRpc(client, companyId, invoiceId, { description: 'x' })).toEqual({ ok: false, code: 'NO_ROWS' })
expect(await callRpc(client, companyId, invoiceId, ['x'])).toEqual({ ok: false, code: 'INVALID_ROWS' })
// A column the mapper started emitting must fail loudly, not be dropped.
expect(await callRpc(client, companyId, invoiceId, [{ ...ROWS[0], invoice_id: invoiceId }])).toEqual({
ok: false, code: 'UNKNOWN_COLUMN', details: { column: 'invoice_id' },
})
// A partial header would null the columns it omits.
expect(await callRpc(client, companyId, invoiceId, ROWS, { subtotal: 1000 })).toEqual({
ok: false, code: 'INVALID_HEADER',
})
})
expect(await storedRows(invoiceId)).toHaveLength(0)
})
it('refuses an invoice outside the company and callers without a write role', async () => {
const a = await seedCompany()
const b = await seedCompany()
const invoiceA = await insertInvoice(a.companyId, a.userId)
// Owner of B naming their own company with A's invoice, and owner of A
// naming company B: both are "not found", never a write.
await withUserContext(b.userId, async (client) => {
expect(await callRpc(client, b.companyId, invoiceA, ROWS)).toEqual({ ok: false, code: 'INVOICE_NOT_FOUND' })
expect(await callRpc(client, a.companyId, invoiceA, ROWS)).toEqual({ ok: false, code: 'FORBIDDEN' })
})
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId: a.companyId, userId: viewer, role: 'viewer' })
await withUserContext(viewer, async (client) => {
expect(await callRpc(client, a.companyId, invoiceA, ROWS)).toEqual({ ok: false, code: 'FORBIDDEN' })
})
// No JWT at all (a plain connection) is refused, not trusted.
const client = await getClient()
try {
expect(await callRpc(client, a.companyId, invoiceA, ROWS)).toEqual({ ok: false, code: 'FORBIDDEN' })
} finally {
client.release()
}
expect(await storedRows(invoiceA)).toHaveLength(0)
})
it('lets the cron write on the service client, with no session user', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const r = await runAsServiceRole((client) => callRpc(client, companyId, invoiceId, ROWS, HEADER))
expect(r).toEqual({ ok: true, wrote: true, rows: 2, header_updated: true })
expect(await storedRows(invoiceId)).toHaveLength(2)
expect(await storedHeader(invoiceId)).toEqual({ ...HEADER, total: 1250 })
})
it('serializes two writers on one invoice: the second waits on the lock and then finds the rows', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(companyId, userId)
const first = await getClient()
const second = await getClient()
try {
await beginAsUser(first, userId)
await beginAsUser(second, userId)
expect(await callRpc(first, companyId, invoiceId, ROWS, HEADER)).toMatchObject({ ok: true, wrote: true, rows: 2 })
let settled = false
const pending = callRpc(second, companyId, invoiceId, [{ ...ROWS[0], description: 'Dubblett' }]).then((r) => {
settled = true
return r
})
await new Promise((resolve) => setTimeout(resolve, 300))
// Blocked on the first writer's row lock, not running ahead of it.
expect(settled).toBe(false)
await first.query('COMMIT')
expect(await pending).toEqual({ ok: true, wrote: false, rows: 0, header_updated: false })
await second.query('COMMIT')
} catch (err) {
await first.query('ROLLBACK').catch(() => {})
await second.query('ROLLBACK').catch(() => {})
throw err
} finally {
first.release()
second.release()
}
expect((await storedRows(invoiceId)).map((r) => r.description)).toEqual(['Konsulttid', 'Resa'])
})
it('grants: anon and PUBLIC have no EXECUTE; authenticated and service_role do', async () => {
const { rows } = await getPool().query<{ anon_can: boolean; public_can: boolean; auth_can: boolean; service_can: boolean }>(
`SELECT has_function_privilege('anon', $1, 'EXECUTE') AS anon_can,
has_function_privilege('public', $1, 'EXECUTE') AS public_can,
has_function_privilege('authenticated', $1, 'EXECUTE') AS auth_can,
has_function_privilege('service_role', $1, 'EXECUTE') AS service_can`,
[SIGNATURE],
)
expect(rows[0]).toEqual({ anon_can: false, public_can: false, auth_can: true, service_can: true })
})
})