Files
accounted/tests/pg/complete-invoice-rows-rpc.pg.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01: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 })
})
})