39d409d257
* 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>
363 lines
15 KiB
TypeScript
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 })
|
|
})
|
|
})
|