feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)

* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps

- MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server)
- DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000)
- Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate
- Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3

Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work.

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

* feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool

Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid
ROT/RUT invoices — no submission API exists, the file is uploaded manually
at skatteverket.se. Headless by design for now: API routes + MCP tool
(gnubok_generate_rot_rut_file), no UI surfaces.

- lib/invoices/rot-rut-file.ts: pure XML generator with deterministic
  per-invoice blockers (hours, work type, personnummer, property info,
  mixed rot+rut, XSD limits) + 31 January deadline warnings
- rot_rut_payout_requests(+items) tables: one active begäran per invoice
  (DB triggers incl. reactivation guard), RLS, audit, pg-real tests
- Settlement: POST /settle books debit 1930 / credit 1513 via the engine
  (source_type rot_rut_payout); partial payouts → partially_paid
- Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only,
  snöskottning/tillsyn/tvätt added (schablontjänster utfört-only)
- Fix: invoice-level fastighetsbeteckning was validated but never
  persisted — now stamped onto rot lines in build-invoice-write; API
  accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred)
- invoice_items.brf_org_number migration + MCP scope invoices:write

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

* feat(invoices): per-company editable invoice email texts

Add an "E-posttexter" section under Settings -> Fakturering where the
subject, greeting, body and sign-off of the standard invoice email can
be customized per company in Swedish and English. Fields pre-fill with
the standard texts and only diffs from the standard are stored
(company_settings.invoice_email_texts JSONB), so future improvements to
the stock wording still reach companies that have not customized. Each
field has a reset-to-standard button; cleared fields snap back.

Texts support a fixed placeholder set (invoice number, customer name,
first name, company, due date, amount) substituted at send time in a
single pass; unknown placeholders stay literal. Custom texts are
HTML-escaped after substitution, newlines become <br> in the HTML
variant, and subject lines are flattened to a single header line.
Overrides apply to standard invoices only - credit notes, proforma and
delivery notes keep the stock texts. All send paths (UI, v1 API, MCP
approval, recurring) pick the texts up via the existing settings row.

The Zod schema half of this change (InvoiceEmailTextsSchema in
lib/api/schemas.ts) was inadvertently included in 8291f745.

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

* fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400

detectFileMagic required the %PDF- signature at byte 0 (BOM aside),
rejecting genuine PDFs that carry a leading newline or junk bytes —
files every ISO 32000 reader opens fine. Now scan the first 1024 bytes
for the signature, matching real-reader behavior. Image types stay
strict at offset 0 to keep the anti-placeholder defense tight.

Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED
(500 'Filen kunde inte sparas'), blaming storage for a client-side file
problem. Both upload routes now map them to a new
DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): full keyboard flow for manual journal entry

Enter now drives the whole verifikat flow: verifikationstext drops into
the first row missing an account, konto commits advance to debet, Enter
on an empty debet hops to kredit, and an entered amount jumps to the
next row. Once the voucher balances, Enter opens the review (unchanged
gate) and the auto-focused confirm posts it — including through the
no-underlag warning dialog. Escape in the inline review goes back to
the form.

Also fixes an Enter footgun in AccountCombobox: a bare Enter on a
freshly focused field no longer selects the first account in the list —
selection now requires typing or arrow navigation; otherwise Enter
re-commits the current value or bubbles to the form-level handler.

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

* feat: add custom inbound domains management for companies

- Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API.
- Created a new table `company_inbound_domains` to store domain information, including status and DNS records.
- Added necessary RLS policies to restrict access based on user roles (owner/admin).
- Developed functions for domain normalization, validation, claiming, verification, and removal.
- Implemented webhook handling for domain status updates from Resend.
- Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature.

* fix: address PR #878 review findings and CI failures

- migrations: drop the ai_usage_tracking policy block from the role-gate
  migration — the table was removed by 20260504120000_remove_ai_subsystem
  and only lingers on staging as drift; a from-scratch chain (pg-real,
  Supabase preview) failed on it
- invoice-inbox: never flip a custom domain to verified off a domain.updated
  webhook alone — confirm the receiving capability with Resend first
  (fail-closed); normalize both sides of the orphan-adoption domain match
- rot/rut: block files where begärt belopp exceeds what the buyer paid
  (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real
  orgnr shapes; parameterize the settlement bank account (19xx, default 1930)
- rot/rut routes: log acting user on financial mutations, stop swallowing
  item mirror errors, narrow response projections (no customer ids through
  the invoice join); document the deliberate inline-XML decision
- documents: stop echoing raw storage-layer error messages to clients

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

* fix: round-2 CI + compliance findings on PR #878

- migrations: the role-gate migration targeted automation_webhooks, which
  20260515170000_webhooks_v2 renamed to webhooks on the canonical chain
  (staging kept the old name — drift); gate public.webhooks instead,
  dropping legacy schema-sync policy names defensively. Restore the
  20260623130000 owner fallback in next_voucher_number that the stale
  copied-verbatim body silently reverted (caught by engine.pg locally).
  Full migration chain verified from scratch against supabase/postgres:15.
- mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877
  qualified-identifier schemas plus this branch's rot/rut tool crossed the
  ceiling only in combination; documented in the test's history log.
- rot/rut: refuse partial settlement before Skatteverkets beslut is
  recorded (would bypass the PATCH lifecycle and strand the request);
  block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on
  12-digit brf orgnr in both schema validation and normalizeBrfOrgNr

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

* fix: rename branch migrations off main's colliding versions

After the merge with main, two versions were shared by two files each
(20260702100000: rot_rut_payout_requests vs company_settings_dimensions_
enabled; 20260702130000: invoice_email_texts vs pending_operations_add_
create_dimension_value). psql-based CI applies by filename and doesn't
care, but Supabase branching records migrations by version (PK) — the
second file with the same version breaks the preview with a
schema_migrations_pkey duplicate. Neither branch migration is version-
recorded on staging or prod, so renaming to fresh 20260703 versions is
safe; nothing between the old and new positions depends on these objects.

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

* fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces

Any Authorization header — attacker-controlled — used to skip the AAL2
gate for every /api route, so a stolen-password AAL1 cookie session could
reach cookie-authenticated routes (which ignore the header) by attaching
`Authorization: x`. The skip is now scoped to the surfaces whose auth
contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth
tokens); pure Bearer callers elsewhere (cron secret, signed webhooks)
carry no cookie session and were never touched by the gate, which only
fires for cookie users. Superagent P2 on PR #878.

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

* test: normalize path separators in dimension statutory guard scan

The route scan compared walked file paths against a POSIX-path allowlist,
so the suite failed on Windows (backslash separators) while passing on
Linux CI. Normalize the scanned paths to forward slashes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-03 13:57:59 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 678f2ccffd
commit 237b77a366
61 changed files with 7695 additions and 394 deletions
+1
View File
@@ -583,6 +583,7 @@ export function makeCompanySettings(
invoice_font_family: 'Helvetica',
invoice_header_text: null,
invoice_footer_text: null,
invoice_email_texts: null,
send_invoice_reminders: true,
reminder_fee_enabled: true,
reminder_fee_amount: 60,
+168
View File
@@ -0,0 +1,168 @@
import { randomUUID } from 'node:crypto'
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
// pg-real coverage for 20260701090000_company_inbound_domains: RLS (member
// SELECT, owner/admin-only writes), the two unique indexes (global
// lower(domain), one domain per company), the status CHECK, and the
// updated_at + audit triggers.
async function insertDomain(
companyId: string,
overrides: { domain?: string; status?: string } = {},
): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.company_inbound_domains (id, company_id, domain, status)
VALUES ($1, $2, $3, $4)`,
[
id,
companyId,
overrides.domain ?? `faktura-${id.slice(0, 8)}.example`,
overrides.status ?? 'pending',
],
)
return id
}
describe('company_inbound_domains RLS', () => {
it('lets company members read, strangers see nothing', async () => {
const { userId, companyId } = await seedCompany()
const rowId = await insertDomain(companyId)
const stranger = await insertAuthUser()
const ownerView = await withUserContext(userId, (client) =>
client.query<{ id: string }>(
`SELECT id FROM public.company_inbound_domains WHERE id = $1`,
[rowId],
),
)
expect(ownerView.rows).toHaveLength(1)
const strangerView = await withUserContext(stranger, (client) =>
client.query<{ id: string }>(
`SELECT id FROM public.company_inbound_domains WHERE id = $1`,
[rowId],
),
)
expect(strangerView.rows).toHaveLength(0)
})
it('lets viewers read but not insert', async () => {
const { companyId } = await seedCompany()
const rowId = await insertDomain(companyId)
const viewer = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
const viewerRead = await withUserContext(viewer, (client) =>
client.query<{ id: string }>(
`SELECT id FROM public.company_inbound_domains WHERE id = $1`,
[rowId],
),
)
expect(viewerRead.rows).toHaveLength(1)
const otherCompany = await seedCompany()
await insertCompanyMember({ companyId: otherCompany.companyId, userId: viewer, role: 'viewer' })
await expect(
withUserContext(viewer, (client) =>
client.query(
`INSERT INTO public.company_inbound_domains (company_id, domain)
VALUES ($1, $2)`,
[otherCompany.companyId, `viewer-claim-${randomUUID().slice(0, 8)}.example`],
),
),
).rejects.toThrow(/row-level security/i)
})
it('lets admins insert and delete', async () => {
const { companyId } = await seedCompany()
const admin = await insertAuthUser()
await insertCompanyMember({ companyId, userId: admin, role: 'admin' })
// withUserContext always rolls back, so assert the insert via RETURNING
// inside the same context.
const inserted = await withUserContext(admin, (client) =>
client.query<{ id: string }>(
`INSERT INTO public.company_inbound_domains (company_id, domain)
VALUES ($1, $2) RETURNING id`,
[companyId, `admin-claim-${randomUUID().slice(0, 8)}.example`],
),
)
expect(inserted.rows).toHaveLength(1)
// Seed via superuser (persists) so the RLS DELETE has a row to hit.
const rowId = await insertDomain(companyId)
const deleted = await withUserContext(admin, (client) =>
client.query<{ id: string }>(
`DELETE FROM public.company_inbound_domains WHERE id = $1 RETURNING id`,
[rowId],
),
)
expect(deleted.rows).toHaveLength(1)
})
})
describe('company_inbound_domains constraints', () => {
it('enforces one owner per domain globally, case-insensitively', async () => {
const { companyId } = await seedCompany()
const other = await seedCompany()
// Random per run — superuser seeds persist across runs on a local DB.
const domain = `unique-claim-${randomUUID().slice(0, 8)}.example`
await insertDomain(companyId, { domain })
await expect(
insertDomain(other.companyId, { domain: domain.toUpperCase() }),
).rejects.toThrow(/duplicate|unique/i)
})
it('enforces one custom domain per company', async () => {
const { companyId } = await seedCompany()
await insertDomain(companyId)
await expect(insertDomain(companyId)).rejects.toThrow(/duplicate|unique/i)
})
it('rejects unknown status values', async () => {
const { companyId } = await seedCompany()
await expect(
insertDomain(companyId, { status: 'sortof-verified' }),
).rejects.toThrow(/check/i)
})
})
describe('company_inbound_domains triggers', () => {
it('bumps updated_at on update', async () => {
const { companyId } = await seedCompany()
const rowId = await insertDomain(companyId)
const before = await getPool().query<{ updated_at: string }>(
`SELECT updated_at FROM public.company_inbound_domains WHERE id = $1`,
[rowId],
)
await getPool().query(
`UPDATE public.company_inbound_domains SET status = 'verified' WHERE id = $1`,
[rowId],
)
const after = await getPool().query<{ updated_at: string }>(
`SELECT updated_at FROM public.company_inbound_domains WHERE id = $1`,
[rowId],
)
expect(new Date(after.rows[0].updated_at).getTime()).toBeGreaterThanOrEqual(
new Date(before.rows[0].updated_at).getTime(),
)
})
it('writes an audit row on insert', async () => {
const { companyId } = await seedCompany()
const rowId = await insertDomain(companyId)
const audit = await getPool().query<{ count: string }>(
`SELECT count(*)::text AS count FROM public.audit_log
WHERE table_name = 'company_inbound_domains' AND record_id = $1`,
[rowId],
)
expect(Number(audit.rows[0].count)).toBeGreaterThanOrEqual(1)
})
})
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertDraftJournalEntry,
insertBalancedLines,
seedCompany,
} from './fixtures'
// Validates migration 20260702093000_rls_role_gate_and_voucher_rpc_guards:
// 1. current_user_can_write() + the write-side RLS policies block `viewer`
// members (and non-members) from mutating tenant data via a direct
// (PostgREST-style) connection, while non-viewer members can write.
// 2. commit_journal_entry() refuses an authenticated caller who is not a
// member of the target company (42501), the cross-tenant hole this
// migration closes, while a member commits normally.
async function setActiveCompany(userId: string, companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyId],
)
}
describe('write-authorization role gate', () => {
it('current_user_can_write() is true for a non-viewer member', async () => {
const { userId, companyId } = await seedCompany() // owner
await setActiveCompany(userId, companyId)
await withUserContext(userId, async (client) => {
const res = await client.query<{ can_write: boolean }>(
`SELECT public.current_user_can_write() AS can_write`,
)
expect(res.rows[0].can_write).toBe(true)
})
})
it('current_user_can_write() is false for a viewer member', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'viewer' })
await setActiveCompany(userId, companyId)
await withUserContext(userId, async (client) => {
const res = await client.query<{ can_write: boolean }>(
`SELECT public.current_user_can_write() AS can_write`,
)
expect(res.rows[0].can_write).toBe(false)
})
})
it('lets a non-viewer member INSERT a customer under RLS', async () => {
const { userId, companyId } = await seedCompany() // owner
await setActiveCompany(userId, companyId)
await withUserContext(userId, async (client) => {
const res = await client.query(
`INSERT INTO public.customers (company_id, user_id, name)
VALUES ($1, $2, 'Acme AB') RETURNING id`,
[companyId, userId],
)
expect(res.rows).toHaveLength(1)
})
})
it('blocks a viewer from INSERTing a customer under RLS', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'viewer' })
await setActiveCompany(userId, companyId)
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.customers (company_id, user_id, name)
VALUES ($1, $2, 'Blocked AB')`,
[companyId, userId],
),
).rejects.toThrow(/row-level security/i)
})
})
})
describe('commit_journal_entry tenant guard', () => {
it('lets a member commit their own draft and assigns a voucher number', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId, companyId, fiscalPeriodId })
await insertBalancedLines(entryId)
await withUserContext(userId, async (client) => {
const res = await client.query<{ voucher_number: number }>(
`SELECT voucher_number FROM public.commit_journal_entry($1, $2)`,
[companyId, entryId],
)
expect(Number(res.rows[0].voucher_number)).toBeGreaterThan(0)
})
})
it('blocks an authenticated non-member from committing another company draft', async () => {
const { userId: ownerId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertDraftJournalEntry({ userId: ownerId, companyId, fiscalPeriodId })
await insertBalancedLines(entryId)
const outsider = await insertAuthUser()
await withUserContext(outsider, async (client) => {
await expect(
client.query(`SELECT voucher_number FROM public.commit_journal_entry($1, $2)`, [
companyId,
entryId,
]),
).rejects.toThrow(/unauthorized: caller is not a member/i)
})
})
})
+248
View File
@@ -0,0 +1,248 @@
/**
* pg-real tests for the rot/rut payout-request schema introduced in
* 20260703090000_rot_rut_payout_requests.sql.
*
* Verifies:
* - invoice_items.brf_org_number exists with its length CHECK.
* - rot_rut_payout_requests / rot_rut_payout_request_items exist with RLS.
* - name/status/deduction_type CHECK constraints.
* - enforce_single_active_rot_rut_request: one active begäran per invoice
* (cross-request), same-company enforcement, retry allowed after
* cancellation/avslag.
* - enforce_rot_rut_request_reactivation: a cancelled request cannot be
* flipped back to active when its invoice meanwhile joined another
* active request.
*/
import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import { insertAuthUser, insertCompany } from './fixtures'
async function seedInvoice(params: {
userId: string
companyId: string
deductionTotal?: number
}): Promise<string> {
const customerId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
VALUES ($1, $2, $3, 'Test Cust', 'individual')`,
[customerId, params.userId, params.companyId],
)
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_date, due_date,
currency, vat_treatment, vat_rate, deduction_total)
VALUES ($1, $2, $3, $4, '2026-06-01', '2026-06-30', 'SEK', 'standard_25', 25, $5)`,
[invoiceId, params.userId, params.companyId, customerId, params.deductionTotal ?? 3000],
)
return invoiceId
}
async function insertRequest(params: {
userId: string
companyId: string
status?: string
name?: string
type?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.rot_rut_payout_requests
(id, company_id, user_id, deduction_type, name, status, requested_total, file_name)
VALUES ($1, $2, $3, $4, $5, $6, 3000, 'rot_2026-07-02.xml')`,
[
id,
params.companyId,
params.userId,
params.type ?? 'rot',
params.name ?? 'ROT 2026-07-02',
params.status ?? 'generated',
],
)
return id
}
async function insertItem(requestId: string, invoiceId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.rot_rut_payout_request_items
(id, request_id, invoice_id, requested_amount)
VALUES ($1, $2, $3, 3000)`,
[id, requestId, invoiceId],
)
return id
}
describe('rot/rut payout-request schema', () => {
it('invoice_items.brf_org_number exists and rejects > 12 chars', async () => {
const cols = await getPool().query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'invoice_items'
AND column_name = 'brf_org_number'`,
)
expect(cols.rows).toHaveLength(1)
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
await expect(
getPool().query(
`INSERT INTO public.invoice_items
(id, invoice_id, sort_order, description, quantity, unit, unit_price,
line_total, vat_rate, vat_amount, brf_org_number)
VALUES ($1, $2, 0, 'X', 1, 'st', 100, 100, 25, 25, '1234567890123')`,
[randomUUID(), invoiceId],
),
).rejects.toThrow()
})
it('both new tables exist with RLS enabled', async () => {
const result = await getPool().query<{ relname: string; relrowsecurity: boolean }>(
`SELECT relname, relrowsecurity FROM pg_class
WHERE relname IN ('rot_rut_payout_requests', 'rot_rut_payout_request_items')`,
)
expect(result.rows).toHaveLength(2)
for (const row of result.rows) {
expect(row.relrowsecurity).toBe(true)
}
})
it('rejects invalid status, deduction_type, and name > 16 chars', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await expect(
insertRequest({ userId, companyId, status: 'weird' }),
).rejects.toThrow()
await expect(
insertRequest({ userId, companyId, type: 'gront' }),
).rejects.toThrow()
await expect(
insertRequest({ userId, companyId, name: 'a'.repeat(17) }),
).rejects.toThrow()
await expect(insertRequest({ userId, companyId, name: '' })).rejects.toThrow()
})
it('happy path: request + item insert, updated_at trigger fires', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const requestId = await insertRequest({ userId, companyId })
await insertItem(requestId, invoiceId)
const before = await getPool().query<{ updated_at: string }>(
`SELECT updated_at FROM public.rot_rut_payout_requests WHERE id = $1`,
[requestId],
)
await getPool().query(
`UPDATE public.rot_rut_payout_requests SET status = 'submitted', submitted_at = now()
WHERE id = $1`,
[requestId],
)
const after = await getPool().query<{ updated_at: string; status: string }>(
`SELECT updated_at, status FROM public.rot_rut_payout_requests WHERE id = $1`,
[requestId],
)
expect(after.rows[0].status).toBe('submitted')
expect(new Date(after.rows[0].updated_at).getTime()).toBeGreaterThanOrEqual(
new Date(before.rows[0].updated_at).getTime(),
)
})
it('blocks the same invoice in two active requests, allows retry after cancel', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const first = await insertRequest({ userId, companyId })
await insertItem(first, invoiceId)
// Second active request with the same invoice → blocked by trigger.
const second = await insertRequest({ userId, companyId })
await expect(insertItem(second, invoiceId)).rejects.toThrow(
/already included in an active/,
)
// Cancel the first request → the invoice becomes requestable again.
await getPool().query(
`UPDATE public.rot_rut_payout_requests SET status = 'cancelled' WHERE id = $1`,
[first],
)
await expect(insertItem(second, invoiceId)).resolves.toBeTruthy()
})
it('allows retry after rejection (avslag)', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const first = await insertRequest({ userId, companyId, status: 'rejected' })
await insertItem(first, invoiceId)
const second = await insertRequest({ userId, companyId })
await expect(insertItem(second, invoiceId)).resolves.toBeTruthy()
})
it('blocks an invoice from another company', async () => {
const userA = await insertAuthUser()
const companyA = await insertCompany({ createdBy: userA })
const userB = await insertAuthUser()
const companyB = await insertCompany({ createdBy: userB })
const foreignInvoice = await seedInvoice({ userId: userB, companyId: companyB })
const requestId = await insertRequest({ userId: userA, companyId: companyA })
await expect(insertItem(requestId, foreignInvoice)).rejects.toThrow(
/does not belong to the same company/,
)
})
it('blocks reactivating a cancelled request whose invoice joined another active request', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const first = await insertRequest({ userId, companyId })
await insertItem(first, invoiceId)
await getPool().query(
`UPDATE public.rot_rut_payout_requests SET status = 'cancelled' WHERE id = $1`,
[first],
)
const second = await insertRequest({ userId, companyId })
await insertItem(second, invoiceId)
// Reactivating the first request would put the invoice in two active
// begäran → blocked.
await expect(
getPool().query(
`UPDATE public.rot_rut_payout_requests SET status = 'generated' WHERE id = $1`,
[first],
),
).rejects.toThrow(/Cannot reactivate/)
})
it('UNIQUE (request_id, invoice_id) blocks duplicate rows in one request', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const requestId = await insertRequest({ userId, companyId })
await insertItem(requestId, invoiceId)
await expect(insertItem(requestId, invoiceId)).rejects.toThrow()
})
it('RESTRICT: an invoice referenced by a request cannot be deleted', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const invoiceId = await seedInvoice({ userId, companyId })
const requestId = await insertRequest({ userId, companyId })
await insertItem(requestId, invoiceId)
await expect(
getPool().query(`DELETE FROM public.invoices WHERE id = $1`, [invoiceId]),
).rejects.toThrow()
})
})