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:
-14
@@ -66,20 +66,6 @@ supabase/.temp/
|
||||
# `npm run taxonomy:check` (core-build.yml) and the golden test pins against
|
||||
# the example document.
|
||||
/dev_docs/*
|
||||
!/dev_docs/bokslut/
|
||||
/dev_docs/bokslut/*
|
||||
!/dev_docs/bokslut/taxonomi/
|
||||
/dev_docs/bokslut/taxonomi/*
|
||||
!/dev_docs/bokslut/taxonomi/taxonomi-paket-2024-09-12_rev20250312.zip
|
||||
!/dev_docs/bokslut/taxonomi/dokumentation/
|
||||
/dev_docs/bokslut/taxonomi/dokumentation/*
|
||||
!/dev_docs/bokslut/taxonomi/dokumentation/k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx
|
||||
!/dev_docs/bokslut/taxonomi/dokumentation/tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx
|
||||
!/dev_docs/bokslut/exempel/
|
||||
/dev_docs/bokslut/exempel/*
|
||||
!/dev_docs/bokslut/exempel/k2/
|
||||
/dev_docs/bokslut/exempel/k2/*
|
||||
!/dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml
|
||||
|
||||
# Extension registry (auto-generated but defaults are committed)
|
||||
# Run `npm run setup:extensions` to regenerate after changing extensions.config.json
|
||||
|
||||
@@ -71,11 +71,20 @@ export const POST = withRouteContext(
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
|
||||
// client problem, not a storage failure — surface as 400 with an
|
||||
// accurate message instead of the misleading "kunde inte sparas".
|
||||
if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) {
|
||||
opLog.warn('document upload rejected by content validation', { reason: message })
|
||||
return errorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
// Full error is logged above; the raw message can leak storage-layer
|
||||
// internals, so the client only gets the generic code + requestId.
|
||||
opLog.error('document upload failed', err as Error)
|
||||
return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { requestId })
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
} from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import type { Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
}))
|
||||
|
||||
const mockCreatePayoutEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({
|
||||
createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET as eligibleGET } from '../eligible/route'
|
||||
import { POST as payoutFilePOST } from '../payout-file/route'
|
||||
import { GET as requestsGET } from '../payout-requests/route'
|
||||
import { PATCH as requestPATCH } from '../payout-requests/[id]/route'
|
||||
import { POST as settlePOST } from '../payout-requests/[id]/settle/route'
|
||||
|
||||
const INVOICE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
|
||||
// Skatteverket official example personnummer (synthetic).
|
||||
const PNR = '198406012388'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function makeRotItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: INVOICE_ID,
|
||||
sort_order: 0,
|
||||
description: 'Snickeri',
|
||||
quantity: 1,
|
||||
unit: 'tim',
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 2500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 3000,
|
||||
labor_hours: 25,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Stockholm Vasastan 1:23',
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makePaidRotInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
||||
return makeInvoice({
|
||||
id: INVOICE_ID,
|
||||
status: 'paid',
|
||||
paid_at: '2026-06-20T10:00:00Z',
|
||||
deduction_total: 3000,
|
||||
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
|
||||
items: [makeRotItem()],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function makePayoutRequestRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: REQUEST_ID,
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
deduction_type: 'rot',
|
||||
name: 'ROT 2026-07-02',
|
||||
status: 'generated',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
file_name: 'rot_begaran_2026-07-02.xml',
|
||||
file_document_id: null,
|
||||
settlement_journal_entry_id: null,
|
||||
submitted_at: null,
|
||||
decided_at: null,
|
||||
created_at: '2026-07-02T00:00:00Z',
|
||||
updated_at: '2026-07-02T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
})
|
||||
|
||||
describe('GET /api/rot-rut/eligible', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('splits invoices into eligible and blocked', async () => {
|
||||
const good = makePaidRotInvoice()
|
||||
const missingHours = makePaidRotInvoice({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
invoice_number: 'F-BAD',
|
||||
items: [makeRotItem({ labor_hours: null })],
|
||||
})
|
||||
enqueue({ data: [good, missingHours] })
|
||||
enqueue({ data: [] }) // no active request items
|
||||
|
||||
const response = await eligibleGET(
|
||||
createMockRequest('/api/rot-rut/eligible', { searchParams: { type: 'rot' } }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { eligible: Array<{ invoice_id: string; begart_belopp: number }>; blocked: Array<{ code: string }> }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.eligible).toHaveLength(1)
|
||||
expect(body.data.eligible[0].invoice_id).toBe(INVOICE_ID)
|
||||
expect(body.data.eligible[0].begart_belopp).toBe(3000)
|
||||
expect(body.data.blocked).toHaveLength(1)
|
||||
expect(body.data.blocked[0].code).toBe('MISSING_HOURS')
|
||||
})
|
||||
|
||||
it('hides invoices already in an active request', async () => {
|
||||
enqueue({ data: [makePaidRotInvoice()] })
|
||||
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', status: 'submitted', company_id: 'company-1' } }] })
|
||||
|
||||
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { eligible: unknown[]; blocked: unknown[] }
|
||||
}>(response)
|
||||
|
||||
expect(body.data.eligible).toHaveLength(0)
|
||||
expect(body.data.blocked).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/rot-rut/payout-file', () => {
|
||||
const validBody = { deduction_type: 'rot', invoice_ids: [INVOICE_ID] }
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on invalid body', async () => {
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', {
|
||||
method: 'POST',
|
||||
body: { deduction_type: 'gront', invoice_ids: [] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('generates the file, records the request and archives the document', async () => {
|
||||
enqueue({ data: [makePaidRotInvoice()] }) // invoices fetch
|
||||
enqueue({ data: makePayoutRequestRow() }) // request insert
|
||||
enqueue({ data: null }) // items insert
|
||||
enqueue({ data: null }) // file_document_id update
|
||||
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { xml: string; file_name: string; arenden: unknown[]; request: { id: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.xml).toContain('<ns2:RotBegaran>')
|
||||
expect(body.data.xml).toContain(`<ns2:Kopare>${PNR}</ns2:Kopare>`)
|
||||
expect(body.data.arenden).toHaveLength(1)
|
||||
expect(body.data.request.id).toBe(REQUEST_ID)
|
||||
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects all-or-nothing when a selected invoice is blocked', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makePaidRotInvoice(),
|
||||
makePaidRotInvoice({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
status: 'sent',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
deduction_type: 'rot',
|
||||
invoice_ids: [INVOICE_ID, '33333333-3333-4333-8333-333333333333'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { blockers: Array<{ code: string }> } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED')
|
||||
})
|
||||
|
||||
it('returns 404 when an invoice id does not belong to the company', async () => {
|
||||
enqueue({ data: [] })
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('maps the double-request trigger to 409 and rolls back the header row', async () => {
|
||||
enqueue({ data: [makePaidRotInvoice()] })
|
||||
enqueue({ data: makePayoutRequestRow() })
|
||||
enqueue({ data: null, error: { code: '23505', message: 'already included in an active rot/rut payout request' } })
|
||||
enqueue({ data: null }) // rollback delete
|
||||
|
||||
const response = await payoutFilePOST(
|
||||
createMockRequest('/api/rot-rut/payout-file', { method: 'POST', body: validBody }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('ROT_RUT_INVOICE_CONFLICT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/rot-rut/payout-requests', () => {
|
||||
it('lists requests', async () => {
|
||||
enqueue({ data: [makePayoutRequestRow()] })
|
||||
const response = await requestsGET(createMockRequest('/api/rot-rut/payout-requests'))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/rot-rut/payout-requests/[id]', () => {
|
||||
const routeParams = createMockRouteParams({ id: REQUEST_ID })
|
||||
|
||||
it('returns 404 for an unknown request', async () => {
|
||||
enqueue({ data: null })
|
||||
const response = await requestPATCH(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'submitted' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects an invalid transition', async () => {
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'paid' }) })
|
||||
const response = await requestPATCH(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'submitted' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ROT_RUT_INVALID_STATUS_TRANSITION')
|
||||
})
|
||||
|
||||
it('requires decided_total for partially_paid', async () => {
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
|
||||
const response = await requestPATCH(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'partially_paid' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('marks a generated request as submitted', async () => {
|
||||
enqueue({ data: makePayoutRequestRow() })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted', submitted_at: '2026-07-02T12:00:00Z' }) })
|
||||
|
||||
const response = await requestPATCH(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'submitted' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('submitted')
|
||||
})
|
||||
|
||||
it('records a rejection with decided_total 0', async () => {
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'rejected', decided_total: 0 }) })
|
||||
|
||||
const response = await requestPATCH(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'rejected' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('rejected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/rot-rut/payout-requests/[id]/settle', () => {
|
||||
const routeParams = createMockRouteParams({ id: REQUEST_ID })
|
||||
const settleBody = { payment_date: '2026-07-10' }
|
||||
|
||||
it('returns 404 for an unknown request', async () => {
|
||||
enqueue({ data: null })
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: settleBody,
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('refuses an already settled request', async () => {
|
||||
enqueue({
|
||||
data: makePayoutRequestRow({
|
||||
status: 'paid',
|
||||
settlement_journal_entry_id: 'je-1',
|
||||
}),
|
||||
})
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: settleBody,
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE')
|
||||
})
|
||||
|
||||
it('books the payout and completes the request as paid', async () => {
|
||||
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
|
||||
enqueue({
|
||||
data: makePayoutRequestRow({
|
||||
status: 'paid',
|
||||
settlement_journal_entry_id: 'je-1',
|
||||
decided_total: 3000,
|
||||
}),
|
||||
})
|
||||
enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] })
|
||||
enqueue({ data: null }) // item decided_amount update
|
||||
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: settleBody,
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { journal_entry_id: string; request: { status: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.journal_entry_id).toBe('je-1')
|
||||
expect(body.data.request.status).toBe('paid')
|
||||
expect(mockCreatePayoutEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ amount: 3000, paymentDate: '2026-07-10' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards bank_account to the engine and defaults it to undefined', async () => {
|
||||
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-3' })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-3' }) })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-07-10', bank_account: '1920' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePayoutEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ bankAccount: '1920' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non-19xx bank_account', async () => {
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-07-10', bank_account: '3001' },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('books a partial payout as partially_paid once the beslut is recorded', async () => {
|
||||
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-2' })
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: 2000 }) })
|
||||
enqueue({
|
||||
data: makePayoutRequestRow({ status: 'partially_paid', decided_total: 2000 }),
|
||||
})
|
||||
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-07-10', amount: 2000 },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { request: { status: string } } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.request.status).toBe('partially_paid')
|
||||
})
|
||||
|
||||
it('refuses a partial settlement before the beslut is recorded', async () => {
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted', decided_total: null }) })
|
||||
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: { payment_date: '2026-07-10', amount: 2000 },
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).not.toBe(200)
|
||||
expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE')
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 and does not update the request when the engine fails', async () => {
|
||||
mockCreatePayoutEntry.mockRejectedValue(new Error('period locked'))
|
||||
enqueue({ data: makePayoutRequestRow({ status: 'submitted' }) })
|
||||
|
||||
const response = await settlePOST(
|
||||
createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/settle`, {
|
||||
method: 'POST',
|
||||
body: settleBody,
|
||||
}),
|
||||
routeParams,
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { listRotRutCandidates } from '@/lib/invoices/rot-rut-service'
|
||||
|
||||
/**
|
||||
* GET /api/rot-rut/eligible?type=rot|rut
|
||||
*
|
||||
* Lists paid invoices carrying a ROT/RUT claim that are NOT yet part of an
|
||||
* active begäran om utbetalning, split into:
|
||||
* - eligible: ready for file generation (with the amounts the file will use)
|
||||
* - blocked: excluded, with the exact blocker (same evaluation as the
|
||||
* generator — what this endpoint approves, the file accepts)
|
||||
*/
|
||||
export const GET = withRouteContext('rot_rut.eligible', async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const typeParam = searchParams.get('type')
|
||||
const type = typeParam === 'rut' ? 'rut' : 'rot'
|
||||
|
||||
const result = await listRotRutCandidates(supabase, companyId!, type)
|
||||
if (!result.ok) {
|
||||
log.error('failed to list rot/rut candidates', result.dbError as Error)
|
||||
return errorResponse(result.dbError, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: { type, eligible: result.eligible, blocked: result.blocked },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RotRutPayoutFileSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
|
||||
/**
|
||||
* POST /api/rot-rut/payout-file
|
||||
*
|
||||
* Generates the begäran-om-utbetalning XML (Skatteverket husavdrag, schema
|
||||
* V6) for the selected invoices, records a rot_rut_payout_requests row (one
|
||||
* active begäran per invoice — DB-enforced), archives the file as
|
||||
* räkenskapsinformation, and returns the XML for download.
|
||||
*
|
||||
* All-or-nothing: if any selected invoice fails eligibility the request is
|
||||
* rejected with per-invoice blockers — a silently thinner file would be a
|
||||
* guess about the user's intent.
|
||||
*
|
||||
* DELIBERATE: the XML (which embeds buyers' personnummer, as Skatteverkets
|
||||
* schema requires) is returned inline. The file only exists to be saved and
|
||||
* uploaded manually on skatteverket.se — there is no UI download surface for
|
||||
* this headless flow, and a document-reference indirection would dead-end the
|
||||
* user whenever the (best-effort) archive failed. Transport is TLS,
|
||||
* authenticated, MFA-gated and write-role-gated via withRouteContext.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'rot_rut.payout_file',
|
||||
async (request, ctx) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, RotRutPayoutFileSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const input = validation.data
|
||||
|
||||
const result = await createRotRutPayoutRequest(supabase, companyId!, user.id, {
|
||||
type: input.deduction_type,
|
||||
invoiceIds: input.invoice_ids,
|
||||
name: input.name,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.code, log, {
|
||||
requestId,
|
||||
details: {
|
||||
...(result.blockers ? { blockers: result.blockers } : {}),
|
||||
...(result.missingInvoiceIds ? { missing_invoice_ids: result.missingInvoiceIds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Archive the XML as räkenskapsinformation (7-year retention via the
|
||||
// document WORM chain). Best-effort: the user gets the file either way
|
||||
// and can re-generate; a failed archive must not orphan the begäran.
|
||||
let fileDocumentId: string | null = null
|
||||
try {
|
||||
const buffer = new TextEncoder().encode(result.file.xml!).buffer as ArrayBuffer
|
||||
const doc = await uploadDocument(
|
||||
supabase,
|
||||
user.id,
|
||||
companyId!,
|
||||
{ name: result.file.file_name, buffer },
|
||||
{ upload_source: 'system' },
|
||||
)
|
||||
fileDocumentId = doc.id
|
||||
await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.update({ file_document_id: doc.id })
|
||||
.eq('id', result.request.id as string)
|
||||
} catch (docError) {
|
||||
log.error('failed to archive rot/rut payout file document', docError as Error)
|
||||
}
|
||||
|
||||
log.info('rot/rut payout file generated', {
|
||||
requestId: result.request.id,
|
||||
type: input.deduction_type,
|
||||
arenden: result.file.arenden.length,
|
||||
requestedTotal: result.file.requested_total,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
request: { ...result.request, file_document_id: fileDocumentId },
|
||||
xml: result.file.xml,
|
||||
file_name: result.file.file_name,
|
||||
arenden: result.file.arenden,
|
||||
warnings: result.file.warnings,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RotRutRequestPatchSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { RotRutPayoutRequestStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* Forward-only lifecycle. Reactivation of cancelled/rejected begäran is
|
||||
* deliberately impossible from the API (and double-guarded by the DB
|
||||
* trigger enforce_rot_rut_request_reactivation) — retry after avslag means
|
||||
* generating a NEW file, mirroring how Skatteverkets e-tjänst works.
|
||||
*/
|
||||
const ALLOWED_TRANSITIONS: Record<RotRutPayoutRequestStatus, RotRutPayoutRequestStatus[]> = {
|
||||
generated: ['submitted', 'cancelled'],
|
||||
submitted: ['paid', 'partially_paid', 'rejected', 'cancelled'],
|
||||
paid: [],
|
||||
partially_paid: [],
|
||||
rejected: [],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/rot-rut/payout-requests/[id]
|
||||
*
|
||||
* Advance the begäran lifecycle: mark the file as uploaded (submitted), or
|
||||
* record Skatteverkets beslut (paid / partially_paid / rejected +
|
||||
* decided_total). Booking the actual payout is POST [id]/settle.
|
||||
*/
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'rot_rut.requests.patch',
|
||||
async (request, ctx, { params }) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const { id } = await params
|
||||
|
||||
const validation = await validateBody(request, RotRutRequestPatchSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const input = validation.data
|
||||
|
||||
const { data: payoutRequest, error: fetchError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('*')
|
||||
.eq('company_id', companyId!)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
log.error('failed to fetch rot/rut payout request', fetchError)
|
||||
return errorResponse(fetchError, log, { requestId })
|
||||
}
|
||||
if (!payoutRequest) {
|
||||
return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const from = payoutRequest.status as RotRutPayoutRequestStatus
|
||||
if (!ALLOWED_TRANSITIONS[from]?.includes(input.status)) {
|
||||
return errorResponseFromCode('ROT_RUT_INVALID_STATUS_TRANSITION', log, {
|
||||
requestId,
|
||||
details: { from, to: input.status },
|
||||
})
|
||||
}
|
||||
// Partial approval without the approved amount is meaningless.
|
||||
if (input.status === 'partially_paid' && input.decided_total === undefined) {
|
||||
return errorResponseFromCode('ROT_RUT_INVALID_STATUS_TRANSITION', log, {
|
||||
requestId,
|
||||
details: { from, to: input.status, reason: 'decided_total krävs för delvis beviljad' },
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const update: Record<string, unknown> = { status: input.status }
|
||||
if (input.status === 'submitted') {
|
||||
update.submitted_at = now
|
||||
}
|
||||
if (input.status === 'paid' || input.status === 'partially_paid' || input.status === 'rejected') {
|
||||
update.decided_at = now
|
||||
update.decided_total =
|
||||
input.decided_total ??
|
||||
(input.status === 'paid' ? payoutRequest.requested_total : 0)
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.update(update)
|
||||
.eq('company_id', companyId!)
|
||||
.eq('id', id)
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, submitted_at, decided_at, settlement_journal_entry_id',
|
||||
)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
log.error('failed to update rot/rut payout request', updateError)
|
||||
return errorResponse(updateError, log, { requestId })
|
||||
}
|
||||
|
||||
// Status transitions record Skatteverkets beslut — the audit trail must
|
||||
// show who recorded them.
|
||||
log.info('rot/rut payout request status changed', {
|
||||
userId: user.id,
|
||||
payoutRequestId: id,
|
||||
from,
|
||||
to: input.status,
|
||||
decidedTotal: update.decided_total ?? null,
|
||||
})
|
||||
|
||||
// Full approval: mirror the per-invoice godkänt belopp onto the items.
|
||||
// Partial approval leaves item amounts null — the split is only known
|
||||
// from Skatteverkets beslut, never guessed.
|
||||
if (input.status === 'paid' && input.decided_total === undefined) {
|
||||
const { data: items, error: itemsFetchError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('id, requested_amount')
|
||||
.eq('request_id', id)
|
||||
if (itemsFetchError) {
|
||||
log.warn('failed to fetch items for decided_amount mirror', {
|
||||
requestId: id,
|
||||
message: itemsFetchError.message,
|
||||
})
|
||||
} else {
|
||||
for (const item of items ?? []) {
|
||||
const { error: mirrorError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.update({ decided_amount: item.requested_amount })
|
||||
.eq('id', item.id)
|
||||
if (mirrorError) {
|
||||
log.warn('failed to mirror decided_amount onto item', {
|
||||
itemId: item.id,
|
||||
message: mirrorError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RotRutSettleSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries'
|
||||
|
||||
/**
|
||||
* POST /api/rot-rut/payout-requests/[id]/settle
|
||||
*
|
||||
* Books Skatteverkets utbetalning for a begäran:
|
||||
*
|
||||
* Debit 19xx bank account (default 1930) [amount]
|
||||
* Credit 1513 Skattereduktion rot/rut [amount]
|
||||
*
|
||||
* The journal entry IS the accounting record here, so engine failure blocks
|
||||
* the operation (see .claude/skills/erp-api-route — payment entries block).
|
||||
* amount defaults to decided_total, falling back to requested_total. If the
|
||||
* amount equals requested_total the request completes as 'paid'; anything
|
||||
* lower records 'partially_paid' with decided_total = amount.
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'rot_rut.requests.settle',
|
||||
async (request, ctx, { params }) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const { id } = await params
|
||||
|
||||
const validation = await validateBody(request, RotRutSettleSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const input = validation.data
|
||||
|
||||
const { data: payoutRequest, error: fetchError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('*')
|
||||
.eq('company_id', companyId!)
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
log.error('failed to fetch rot/rut payout request', fetchError)
|
||||
return errorResponse(fetchError, log, { requestId })
|
||||
}
|
||||
if (!payoutRequest) {
|
||||
return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
const settleable =
|
||||
!payoutRequest.settlement_journal_entry_id &&
|
||||
!['cancelled', 'rejected'].includes(payoutRequest.status)
|
||||
if (!settleable) {
|
||||
return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, {
|
||||
requestId,
|
||||
details: {
|
||||
status: payoutRequest.status,
|
||||
already_settled: !!payoutRequest.settlement_journal_entry_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const amount =
|
||||
input.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total)
|
||||
|
||||
// A partial settlement must follow a recorded beslut: without this guard a
|
||||
// settle with amount < requested_total on an undecided request would flip
|
||||
// it to partially_paid while bypassing the PATCH lifecycle rule that
|
||||
// partially_paid requires decided_total — the beslut would never be
|
||||
// recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS.
|
||||
if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) {
|
||||
return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, {
|
||||
requestId,
|
||||
details: {
|
||||
status: payoutRequest.status,
|
||||
reason:
|
||||
'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The voucher is the accounting record — engine failure must block.
|
||||
let journalEntryId: string
|
||||
try {
|
||||
const entry = await createRotRutPayoutEntry(supabase, companyId!, user.id, {
|
||||
requestId: payoutRequest.id,
|
||||
requestName: payoutRequest.name,
|
||||
deductionType: payoutRequest.deduction_type,
|
||||
paymentDate: input.payment_date,
|
||||
amount,
|
||||
bankAccount: input.bank_account,
|
||||
})
|
||||
journalEntryId = entry.id
|
||||
} catch (engineError) {
|
||||
log.error('failed to book rot/rut payout entry', engineError as Error)
|
||||
return errorResponse(engineError, log, { requestId })
|
||||
}
|
||||
|
||||
const fullyPaid = amount >= Number(payoutRequest.requested_total)
|
||||
const update: Record<string, unknown> = {
|
||||
settlement_journal_entry_id: journalEntryId,
|
||||
status: fullyPaid ? 'paid' : 'partially_paid',
|
||||
decided_total: payoutRequest.decided_total ?? amount,
|
||||
}
|
||||
if (!payoutRequest.decided_at) {
|
||||
update.decided_at = new Date().toISOString()
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.update(update)
|
||||
.eq('company_id', companyId!)
|
||||
.eq('id', id)
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id',
|
||||
)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
// The voucher exists (immutable per BFL) but the request row didn't
|
||||
// absorb the link — surface loudly, do NOT try to unbook.
|
||||
log.error('rot/rut payout entry booked but request update failed', updateError, {
|
||||
journalEntryId,
|
||||
payoutRequestId: id,
|
||||
})
|
||||
return errorResponse(updateError, log, { requestId })
|
||||
}
|
||||
|
||||
if (fullyPaid) {
|
||||
const { data: items, error: itemsFetchError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('id, requested_amount')
|
||||
.eq('request_id', id)
|
||||
if (itemsFetchError) {
|
||||
log.warn('failed to fetch items for decided_amount mirror', {
|
||||
payoutRequestId: id,
|
||||
message: itemsFetchError.message,
|
||||
})
|
||||
}
|
||||
for (const item of items ?? []) {
|
||||
const { error: mirrorError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.update({ decided_amount: item.requested_amount })
|
||||
.eq('id', item.id)
|
||||
if (mirrorError) {
|
||||
log.warn('failed to mirror decided_amount onto item', {
|
||||
itemId: item.id,
|
||||
message: mirrorError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info('rot/rut payout settled', {
|
||||
userId: user.id,
|
||||
payoutRequestId: id,
|
||||
journalEntryId,
|
||||
amount,
|
||||
fullyPaid,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { request: updated, journal_entry_id: journalEntryId } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/rot-rut/payout-requests
|
||||
*
|
||||
* Request history (all statuses), newest first, with per-invoice items.
|
||||
*/
|
||||
export const GET = withRouteContext('rot_rut.requests.list', async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const typeFilter = searchParams.get('type')
|
||||
|
||||
// Explicit projections — the history list needs identifiers and amounts,
|
||||
// not every column (and never customer ids through the invoice join).
|
||||
let query = supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, file_name, file_document_id, ' +
|
||||
'created_at, submitted_at, decided_at, settlement_journal_entry_id, ' +
|
||||
'items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, ' +
|
||||
'invoice:invoices(id, invoice_number))',
|
||||
)
|
||||
.eq('company_id', companyId!)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (typeFilter === 'rot' || typeFilter === 'rut') {
|
||||
query = query.eq('deduction_type', typeFilter)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
log.error('failed to list rot/rut payout requests', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
})
|
||||
@@ -289,10 +289,22 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'unknown'
|
||||
// Magic-byte validation rejections (validateDocumentMagicBytes) are a
|
||||
// client problem, not a storage failure — surface as 400 with an
|
||||
// accurate message instead of the misleading "kunde inte sparas".
|
||||
if (/kunde inte verifieras|matchar inte den angivna filtypen/i.test(message)) {
|
||||
opLog.warn('document upload rejected by content validation', { reason: message })
|
||||
return v1ErrorResponseFromCode('DOC_UPLOAD_INVALID_CONTENT', opLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: message },
|
||||
})
|
||||
}
|
||||
// Full error is logged above; the raw message can leak storage-layer
|
||||
// internals, so the client only gets the generic code + requestId.
|
||||
opLog.error('document upload failed', err as Error)
|
||||
return v1ErrorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,6 +49,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const internalInputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// Whether the user has typed or arrow-navigated since the field was focused.
|
||||
// Enter only selects the highlighted item after an actual interaction — a
|
||||
// bare Enter on a freshly-focused field must not grab the first account in
|
||||
// the list (it either re-commits the current value or bubbles to the form).
|
||||
const hasInteractedRef = useRef(false)
|
||||
|
||||
// Attach the internal ref (used for focus bookkeeping) and forward the element
|
||||
// to any external callback ref the parent passed.
|
||||
@@ -140,8 +145,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
hasInteractedRef.current = true
|
||||
setIsOpen(true)
|
||||
e.preventDefault()
|
||||
} else if (e.key === 'Enter' && /^\d{4}$/.test(search)) {
|
||||
// Dropdown closed but a full account number sits in the field — treat
|
||||
// Enter as a re-commit so focus advances to the amount field.
|
||||
e.preventDefault()
|
||||
onCommit?.(search)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -149,16 +160,27 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
hasInteractedRef.current = true
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
hasInteractedRef.current = true
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (flatList[highlightedIndex]) {
|
||||
if (hasInteractedRef.current && flatList[highlightedIndex]) {
|
||||
e.preventDefault()
|
||||
selectAccount(flatList[highlightedIndex].account_number)
|
||||
} else if (/^\d{4}$/.test(search)) {
|
||||
// Committed number, no new interaction — advance without re-selecting.
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
onCommit?.(search)
|
||||
} else {
|
||||
// Nothing actively chosen — close the list and let the event bubble
|
||||
// so the form-level Enter (open review when balanced) can take over.
|
||||
setIsOpen(false)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
@@ -170,6 +192,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
hasInteractedRef.current = true
|
||||
setSearch(newValue)
|
||||
// Emit any 4-digit numeric value to the parent. Unknown BAS numbers are
|
||||
// accepted optimistically — the submit-time ActivateAccountsDialog lets
|
||||
@@ -193,6 +216,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
hasInteractedRef.current = false
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,11 +165,16 @@ export default function JournalEntryForm({
|
||||
// user typed in the combobox so we can prefill the dialog.
|
||||
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
|
||||
const [createAccountPrefill, setCreateAccountPrefill] = useState<string>('')
|
||||
// Per-row refs to the debit inputs so we can auto-advance focus there once an
|
||||
// account is committed on a row. Two layouts render simultaneously (mobile
|
||||
// cards + desktop table); we focus whichever one is actually visible.
|
||||
// Per-row refs to the account/debit/credit inputs so the keyboard flow can
|
||||
// advance focus with Enter: konto → debet → kredit → nästa rads konto. Two
|
||||
// layouts render simultaneously (mobile cards + desktop table); we focus
|
||||
// whichever one is actually visible.
|
||||
const desktopAccountRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileAccountRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const desktopCreditRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
const mobileCreditRefs = useRef<(HTMLInputElement | null)[]>([])
|
||||
// Confirm button in the inline (bare) review, focused on open so Enter posts.
|
||||
const bareConfirmRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -512,18 +517,37 @@ export default function JournalEntryForm({
|
||||
updateLine(index, side === 'debit' ? 'debit_amount' : 'credit_amount', fill.toFixed(2))
|
||||
}
|
||||
|
||||
// Move focus to a row's debit input. Deferred a frame so it runs after any
|
||||
// Move focus to a row's input. Deferred a frame so it runs after any
|
||||
// re-render (e.g. the auto-appended trailing row). offsetParent is null for
|
||||
// display:none elements, so this picks whichever layout is currently visible.
|
||||
const focusDebit = useCallback((index: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
const d = desktopDebitRefs.current[index]
|
||||
const m = mobileDebitRefs.current[index]
|
||||
const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m)
|
||||
target?.focus()
|
||||
target?.select?.()
|
||||
})
|
||||
}, [])
|
||||
const focusRowInput = useCallback(
|
||||
(
|
||||
desktop: React.RefObject<(HTMLInputElement | null)[]>,
|
||||
mobile: React.RefObject<(HTMLInputElement | null)[]>,
|
||||
index: number
|
||||
) => {
|
||||
requestAnimationFrame(() => {
|
||||
const d = desktop.current?.[index]
|
||||
const m = mobile.current?.[index]
|
||||
const target = d && d.offsetParent !== null ? d : m && m.offsetParent !== null ? m : (d ?? m)
|
||||
target?.focus()
|
||||
target?.select?.()
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
const focusAccount = useCallback(
|
||||
(index: number) => focusRowInput(desktopAccountRefs, mobileAccountRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
const focusDebit = useCallback(
|
||||
(index: number) => focusRowInput(desktopDebitRefs, mobileDebitRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
const focusCredit = useCallback(
|
||||
(index: number) => focusRowInput(desktopCreditRefs, mobileCreditRefs, index),
|
||||
[focusRowInput]
|
||||
)
|
||||
|
||||
// Keep exactly one trailing blank row so the user never has to click "Lägg
|
||||
// till rad": once the last row is started (account or amount), append a fresh
|
||||
@@ -676,6 +700,44 @@ export default function JournalEntryForm({
|
||||
if (canSubmitReview()) handleReview()
|
||||
}
|
||||
|
||||
// Enter-to-advance inside the konteringsrader: konto → debet → kredit →
|
||||
// nästa rads konto. Navigation only fires while the entry is NOT
|
||||
// submittable — once the voucher balances, Enter falls through to the
|
||||
// form-level handler above and opens the review instead, so a single Enter
|
||||
// never both moves focus and submits.
|
||||
const handleAmountKeyDown =
|
||||
(index: number, side: 'debit' | 'credit') =>
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
e.preventDefault()
|
||||
// An amount on this side finishes the row (debit clears credit and vice
|
||||
// versa) → jump to the next row's account. An empty debit means the row
|
||||
// books on the credit side → hop across first.
|
||||
if (side === 'debit' && !(parseFloat(lines[index].debit_amount) > 0)) {
|
||||
focusCredit(index)
|
||||
} else {
|
||||
focusAccount(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Enter in a radbeskrivning continues to that row's amount.
|
||||
const handleLineDescKeyDown =
|
||||
(index: number) => (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
e.preventDefault()
|
||||
focusDebit(index)
|
||||
}
|
||||
|
||||
// Enter in the verifikationstext drops into the first row still missing an
|
||||
// account, so the top-to-bottom keyboard flow never needs the mouse.
|
||||
const handleHeaderDescKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key !== 'Enter' || canSubmitReview()) return
|
||||
const idx = lines.findIndex((l) => !l.account_number)
|
||||
if (idx === -1) return
|
||||
e.preventDefault()
|
||||
focusAccount(idx)
|
||||
}
|
||||
|
||||
// Inner submit: builds payload, POSTs, throws a structured error on failure
|
||||
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
|
||||
const postJournalEntry = useCallback(async () => {
|
||||
@@ -929,7 +991,17 @@ export default function JournalEntryForm({
|
||||
// summary instead of stacking a second dialog over the form dialog. The
|
||||
// no-underlag caveat folds in here so there's a single confirm step.
|
||||
const reviewPanel = (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="space-y-4"
|
||||
// The host dialog swallows Escape (accidental-close guard), so Escape
|
||||
// here is free to mean "back to the form" — keyboard mirror of ←.
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !isSubmitting) {
|
||||
e.stopPropagation()
|
||||
setShowReview(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1022,6 +1094,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleHeaderDescKeyDown}
|
||||
placeholder={t('description_placeholder')}
|
||||
className="mt-1 h-8"
|
||||
/>
|
||||
@@ -1223,6 +1296,7 @@ export default function JournalEntryForm({
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
onCommit={() => focusDebit(index)}
|
||||
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
|
||||
inputRef={(el) => { mobileAccountRefs.current[index] = el }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1238,6 +1312,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
onKeyDown={handleLineDescKeyDown(index)}
|
||||
placeholder={t('line_description_placeholder')}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -1248,6 +1323,7 @@ export default function JournalEntryForm({
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1260,9 +1336,11 @@ export default function JournalEntryForm({
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('col_credit')}</Label>
|
||||
<Input
|
||||
ref={(el) => { mobileCreditRefs.current[index] = el }}
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1348,6 +1426,7 @@ export default function JournalEntryForm({
|
||||
onChange={(num) => updateLine(index, 'account_number', num)}
|
||||
onCommit={() => focusDebit(index)}
|
||||
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
|
||||
inputRef={(el) => { desktopAccountRefs.current[index] = el }}
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
@@ -1355,6 +1434,7 @@ export default function JournalEntryForm({
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
|
||||
onKeyDown={handleLineDescKeyDown(index)}
|
||||
placeholder={t('line_description_placeholder')}
|
||||
className="h-8"
|
||||
/>
|
||||
@@ -1372,6 +1452,7 @@ export default function JournalEntryForm({
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'debit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'debit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1383,9 +1464,11 @@ export default function JournalEntryForm({
|
||||
</td>
|
||||
<td className="py-1.5 px-1">
|
||||
<Input
|
||||
ref={(el) => { desktopCreditRefs.current[index] = el }}
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
onKeyDown={handleAmountKeyDown(index, 'credit')}
|
||||
onDoubleClick={() => handleFillBalance(index, 'credit')}
|
||||
title={t('fill_balance_tooltip')}
|
||||
placeholder="0,00"
|
||||
@@ -1504,7 +1587,9 @@ export default function JournalEntryForm({
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">{t('fill_balance_hint')}</p>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t('fill_balance_hint')} {t('keyboard_hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Document attachments — hidden when editing a draft; underlag is
|
||||
@@ -1666,6 +1751,7 @@ export default function JournalEntryForm({
|
||||
setShowReview(true)
|
||||
}}
|
||||
isSubmitting={false}
|
||||
autoFocusConfirm
|
||||
title={t('no_doc_dialog_title')}
|
||||
warningText={t('no_doc_dialog_warning')}
|
||||
confirmLabel={t('no_doc_confirm')}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { CompanyInboundDomain, InboundDomainDnsRecord } from '@/types'
|
||||
|
||||
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
CompanyInboundDomain['status'],
|
||||
{ label: string; variant: 'secondary' | 'success' | 'destructive' }
|
||||
> = {
|
||||
pending: { label: 'Väntar på DNS', variant: 'secondary' },
|
||||
verified: { label: 'Verifierad', variant: 'success' },
|
||||
failed: { label: 'Misslyckades', variant: 'destructive' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// Settings dialog for a company's own inbound domain. Claims the domain via
|
||||
// the extension API, renders the DNS records the user must publish, and
|
||||
// re-checks verification on demand. Everything mail-routing happens
|
||||
// server-side — this surface only manages the claim lifecycle.
|
||||
export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [domain, setDomain] = useState<CompanyInboundDomain | null>(null)
|
||||
const [domainInput, setDomainInput] = useState('')
|
||||
const [isClaiming, setIsClaiming] = useState(false)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isRemoving, setIsRemoving] = useState(false)
|
||||
|
||||
const fetchDomain = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(BASE)
|
||||
const json = await res.json()
|
||||
if (res.ok) setDomain(json.data ?? null)
|
||||
} catch {
|
||||
// Leave the previous state; the dialog shows the claim form on null.
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchDomain()
|
||||
}, [open, fetchDomain])
|
||||
|
||||
const handleClaim = useCallback(async () => {
|
||||
if (!domainInput.trim()) return
|
||||
setIsClaiming(true)
|
||||
try {
|
||||
const res = await fetch(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: domainInput }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Kunde inte lägga till domänen')
|
||||
setDomain(json.data)
|
||||
setDomainInput('')
|
||||
toast({
|
||||
title: 'Domän tillagd',
|
||||
description: 'Lägg till DNS-posterna nedan hos din domänleverantör.',
|
||||
})
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte lägga till domänen',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsClaiming(false)
|
||||
}
|
||||
}, [domainInput, toast])
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setIsChecking(true)
|
||||
try {
|
||||
const res = await fetch(`${BASE}/verify`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Kontrollen misslyckades')
|
||||
setDomain(json.data)
|
||||
toast(
|
||||
json.data.status === 'verified'
|
||||
? { title: 'Domänen är verifierad', description: 'E-post till domänen landar nu i dokumentinkorgen.' }
|
||||
: { title: 'Inte verifierad än', description: 'DNS-ändringar kan ta upp till någon timme att slå igenom.' }
|
||||
)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kontrollen misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (!domain) return
|
||||
if (!confirm(`Ta bort ${domain.domain}? E-post till domänen slutar landa i Accounted.`)) return
|
||||
setIsRemoving(true)
|
||||
try {
|
||||
const res = await fetch(BASE, { method: 'DELETE' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Borttagningen misslyckades')
|
||||
setDomain(null)
|
||||
toast({ title: 'Domänen borttagen' })
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Borttagningen misslyckades',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsRemoving(false)
|
||||
}
|
||||
}, [domain, toast])
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(value: string) => {
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
toast({ title: 'Kopierat' })
|
||||
},
|
||||
[toast]
|
||||
)
|
||||
|
||||
const records: InboundDomainDnsRecord[] = domain?.dns_records ?? []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Egen domän för inkorgen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ta emot leverantörsfakturor direkt på bolagets egen adress, t.ex.
|
||||
faktura@dittbolag.se — utan vidarebefordran.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : !domain ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-4 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Viktigt om din domän redan tar emot e-post</p>
|
||||
<p className="text-muted-foreground">
|
||||
Domänens MX-poster pekas om till Accounted. Om domänen redan används för
|
||||
e-post (Google Workspace, Microsoft 365) slutar din vanliga e-post att
|
||||
fungera — använd då en underdomän, t.ex.{' '}
|
||||
<code className="font-mono text-xs">faktura.dittbolag.se</code>, eller
|
||||
fortsätt vidarebefordra till din vanliga inkorgsadress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={domainInput}
|
||||
onChange={(e) => setDomainInput(e.target.value)}
|
||||
placeholder="faktura.dittbolag.se"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleClaim()
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleClaim} disabled={isClaiming || !domainInput.trim()}>
|
||||
{isClaiming ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="font-mono text-sm truncate">{domain.domain}</code>
|
||||
<Badge variant={STATUS_BADGE[domain.status].variant}>
|
||||
{STATUS_BADGE[domain.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleVerify} disabled={isChecking}>
|
||||
{isChecking ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Kontrollera igen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
disabled={isRemoving}
|
||||
aria-label="Ta bort domän"
|
||||
>
|
||||
{isRemoving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domain.status === 'verified' ? (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-4 text-sm">
|
||||
<Check className="h-4 w-4 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
Klart — ge dina leverantörer{' '}
|
||||
<code className="font-mono text-xs">faktura@{domain.domain}</code>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Alla adresser på domänen fungerar; allt landar i dokumentinkorgen.
|
||||
{domain.verified_at ? ` Verifierad ${formatDateLong(domain.verified_at)}.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lägg till posterna nedan hos din domänleverantör (Loopia, one.com,
|
||||
Cloudflare …) och klicka sedan på Kontrollera igen. Ändringar kan ta upp
|
||||
till någon timme att slå igenom.
|
||||
</p>
|
||||
{records.length > 0 ? (
|
||||
<div className="rounded-lg border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Namn</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Värde</th>
|
||||
<th className="px-3 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Prio</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r, i) => (
|
||||
<tr key={`${r.type}-${r.name}-${i}`} className="border-b border-border last:border-0">
|
||||
<td className="px-3 py-2 font-mono text-xs">{r.type}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{r.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs break-all">{r.value}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-right tabular-nums">
|
||||
{r.priority ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleCopy(r.value)}
|
||||
aria-label={`Kopiera ${r.type}-värde`}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga DNS-poster tillgängliga — klicka på Kontrollera igen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import type { InvoiceExtractionResult } from '@/types'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
|
||||
// InboxCustomDomainDialog (egen domän) is built but gated off — see
|
||||
// INBOX_CUSTOM_DOMAINS_ENABLED in extensions/general/invoice-inbox/index.ts.
|
||||
import TransactionMatchPicker from '@/components/inbox/TransactionMatchPicker'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: z.string().nullable().optional(),
|
||||
housing_designation: z.string().nullable().optional(),
|
||||
apartment_number: z.string().nullable().optional(),
|
||||
brf_org_number: z.string().nullable().optional(),
|
||||
// Periodisering (förutbetald intäkt). Active when balance account is
|
||||
// non-null; both period dates are then required (refine below).
|
||||
accrual_period_start: z.string().nullable().optional(),
|
||||
@@ -199,7 +200,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
self_billing_agreement_ref: z.string().optional(),
|
||||
received_date: z.string().optional(),
|
||||
// Invoice-level ROT/RUT claim info. Personnummer is plaintext on
|
||||
// the wire; the API encrypts it before storage.
|
||||
// the wire; the API encrypts it before storage. The API additionally
|
||||
// accepts the bostadsrätt pair (deduction_apartment_number +
|
||||
// deduction_brf_org_number) — no editor UI for it yet, rot i
|
||||
// bostadsrätt data enters via API/MCP until the payout-file UI ships.
|
||||
deduction_personnummer: z.string().optional(),
|
||||
deduction_housing_designation: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
|
||||
@@ -262,6 +266,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// draft. Starts true for create (always derive), false for edit (skip once).
|
||||
const didInitialCustomerSync = useRef(!isEditMode)
|
||||
|
||||
// Edit mode: the claim card's property fields are restored from the first
|
||||
// rot line (they're stamped onto every rot line server-side at save time).
|
||||
const initialRotLine = initial?.items?.find((i) => i.deduction_type === 'rot') ?? null
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
@@ -291,7 +299,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
self_billing_agreement_ref: '',
|
||||
received_date: '',
|
||||
deduction_personnummer: '',
|
||||
deduction_housing_designation: '',
|
||||
deduction_housing_designation: initialRotLine?.housing_designation ?? '',
|
||||
items: (initial.items ?? []).map((item) => ({
|
||||
line_type: (item.line_type ?? 'product') as 'product' | 'text',
|
||||
description: item.description,
|
||||
@@ -306,6 +314,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: item.work_type ?? null,
|
||||
housing_designation: item.housing_designation ?? null,
|
||||
apartment_number: item.apartment_number ?? null,
|
||||
brf_org_number: item.brf_org_number ?? null,
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
@@ -334,6 +343,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
@@ -927,6 +937,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1003,6 +1014,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1061,6 +1073,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: _wt,
|
||||
housing_designation: _hd,
|
||||
apartment_number: _an,
|
||||
brf_org_number: _bn,
|
||||
...rest
|
||||
} = item
|
||||
return rest
|
||||
@@ -1816,6 +1829,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
@@ -1852,6 +1866,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import {
|
||||
INVOICE_EMAIL_DEFAULT_TEXTS,
|
||||
INVOICE_EMAIL_PLACEHOLDER_KEYS,
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import type { CompanySettings, InvoiceEmailTextOverrides, InvoiceEmailTexts } from '@/types'
|
||||
|
||||
interface InvoiceEmailTextsSettingsProps {
|
||||
settings: CompanySettings
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
type Lang = 'sv' | 'en'
|
||||
type Field = keyof InvoiceEmailTextOverrides
|
||||
|
||||
const LANGS: Lang[] = ['sv', 'en']
|
||||
|
||||
const FIELD_CONFIG: Array<{ field: Field; labelKey: string; multiline?: boolean }> = [
|
||||
{ field: 'subject', labelKey: 'subject_label' },
|
||||
{ field: 'greeting', labelKey: 'greeting_label' },
|
||||
{ field: 'body', labelKey: 'body_label', multiline: true },
|
||||
{ field: 'signoff', labelKey: 'signoff_label' },
|
||||
]
|
||||
|
||||
// The editor always shows the EFFECTIVE text (override or standard), never an
|
||||
// empty field — users see and edit the mail that actually goes out.
|
||||
type DisplayTexts = Record<Lang, Record<Field, string>>
|
||||
|
||||
function buildDisplay(stored: InvoiceEmailTexts | null | undefined): DisplayTexts {
|
||||
const result = {} as DisplayTexts
|
||||
for (const lang of LANGS) {
|
||||
result[lang] = {} as Record<Field, string>
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = stored?.[lang]?.[field]
|
||||
result[lang][field] =
|
||||
typeof value === 'string' && value.trim() !== ''
|
||||
? value
|
||||
: INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Cleared fields have no meaning of their own — snap them back to standard.
|
||||
function normalize(display: DisplayTexts): DisplayTexts {
|
||||
const result = {} as DisplayTexts
|
||||
for (const lang of LANGS) {
|
||||
result[lang] = {} as Record<Field, string>
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = display[lang][field]
|
||||
result[lang][field] =
|
||||
value.trim() === '' ? INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] : value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Store only changes: a field equal to the standard text is NOT an override,
|
||||
// so future improvements to the standard wording reach every company that
|
||||
// hasn't customized. Empty result → null (column reads "all defaults").
|
||||
function toOverrides(display: DisplayTexts): InvoiceEmailTexts | null {
|
||||
const result: InvoiceEmailTexts = {}
|
||||
for (const lang of LANGS) {
|
||||
const langOverrides: InvoiceEmailTextOverrides = {}
|
||||
for (const { field } of FIELD_CONFIG) {
|
||||
const value = display[lang][field].trim()
|
||||
if (value !== '' && value !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]) {
|
||||
langOverrides[field] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(langOverrides).length > 0) result[lang] = langOverrides
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
export function InvoiceEmailTextsSettings({ settings, onUpdate }: InvoiceEmailTextsSettingsProps) {
|
||||
const t = useTranslations('settings_email_texts')
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const [texts, setTexts] = useState<DisplayTexts>(() => buildDisplay(settings.invoice_email_texts))
|
||||
// Serialized last-persisted overrides — skips no-op PUTs on blur without
|
||||
// edits. toOverrides() builds keys in a fixed order, so comparison is stable.
|
||||
const lastSavedRef = useRef<string>(
|
||||
JSON.stringify(toOverrides(buildDisplay(settings.invoice_email_texts))),
|
||||
)
|
||||
|
||||
const setField = (lang: Lang, field: Field, value: string) => {
|
||||
setTexts((prev) => ({ ...prev, [lang]: { ...prev[lang], [field]: value } }))
|
||||
}
|
||||
|
||||
// Whole-object save: a JSONB column update replaces the stored value, and
|
||||
// the inactive language tab is unmounted, so per-field PATCHes can't work.
|
||||
const persist = useCallback(async (display: DisplayTexts) => {
|
||||
const overrides = toOverrides(display)
|
||||
const serialized = JSON.stringify(overrides)
|
||||
if (serialized === lastSavedRef.current) return
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_email_texts: overrides }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
lastSavedRef.current = serialized
|
||||
onUpdate({ invoice_email_texts: overrides })
|
||||
} catch {
|
||||
toast({ title: t('toast_save_failed'), variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast, t])
|
||||
|
||||
const handleBlur = () => {
|
||||
const normalized = normalize(texts)
|
||||
setTexts(normalized)
|
||||
void persist(normalized)
|
||||
}
|
||||
|
||||
const resetField = (lang: Lang, field: Field) => {
|
||||
const next = {
|
||||
...texts,
|
||||
[lang]: { ...texts[lang], [field]: INVOICE_EMAIL_DEFAULT_TEXTS[lang][field] },
|
||||
}
|
||||
setTexts(next)
|
||||
void persist(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('heading')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="sv">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sv">{t('tab_sv')}</TabsTrigger>
|
||||
<TabsTrigger value="en">{t('tab_en')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{LANGS.map((lang) => (
|
||||
<TabsContent key={lang} value={lang} className="mt-4 space-y-4">
|
||||
{lang === 'en' && (
|
||||
<p className="text-xs text-muted-foreground">{t('en_tab_hint')}</p>
|
||||
)}
|
||||
{FIELD_CONFIG.map(({ field, labelKey, multiline }) => {
|
||||
const id = `invoice-email-${field}-${lang}`
|
||||
const modified =
|
||||
texts[lang][field].trim() !== INVOICE_EMAIL_DEFAULT_TEXTS[lang][field]
|
||||
const common = {
|
||||
id,
|
||||
value: texts[lang][field],
|
||||
onBlur: handleBlur,
|
||||
disabled: !canWrite,
|
||||
}
|
||||
return (
|
||||
<div key={field} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{t(labelKey)}</Label>
|
||||
{modified && canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetField(lang, field)}
|
||||
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
{t('reset_label')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{multiline ? (
|
||||
<Textarea
|
||||
{...common}
|
||||
rows={4}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
{...common}
|
||||
onChange={(e) => setField(lang, field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* Legend is rendered from code, not messages/*.json — ICU message
|
||||
syntax treats literal braces as interpolation. */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('placeholders_help')}</span>
|
||||
{INVOICE_EMAIL_PLACEHOLDER_KEYS.map((key) => (
|
||||
<code key={key} className="rounded bg-muted px-1 text-xs">{`{${key}}`}</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('firstname_note')}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
|
||||
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
|
||||
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
@@ -73,6 +74,11 @@ export function InvoicingSettingsContent() {
|
||||
<div className="border-t border-border pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
|
||||
{/* Invoice email texts — autosaves on blur */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
// The custom-domain routes are gated off by default (product decision
|
||||
// 2026-07-02) — enable the flag for the behavior tests, and prove the gate
|
||||
// itself in the last describe.
|
||||
beforeEach(() => {
|
||||
process.env.INBOX_CUSTOM_DOMAINS_ENABLED = 'true'
|
||||
})
|
||||
afterEach(() => {
|
||||
delete process.env.INBOX_CUSTOM_DOMAINS_ENABLED
|
||||
})
|
||||
|
||||
const claimMock = vi.fn()
|
||||
const verifyMock = vi.fn()
|
||||
const removeMock = vi.fn()
|
||||
const getMock = vi.fn()
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/custom-domains', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@/extensions/general/invoice-inbox/lib/custom-domains')
|
||||
>('@/extensions/general/invoice-inbox/lib/custom-domains')
|
||||
return {
|
||||
...actual,
|
||||
claimCustomDomain: (...args: unknown[]) => claimMock(...args),
|
||||
checkCustomDomainVerification: (...args: unknown[]) => verifyMock(...args),
|
||||
removeCustomDomain: (...args: unknown[]) => removeMock(...args),
|
||||
getCustomDomain: (...args: unknown[]) => getMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path
|
||||
)!
|
||||
}
|
||||
|
||||
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
...overrides,
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
const DOMAIN_ROW = {
|
||||
id: 'row-1',
|
||||
company_id: 'company-1',
|
||||
domain: 'hansbolag.example',
|
||||
status: 'pending',
|
||||
resend_domain_id: 'rd_1',
|
||||
dns_records: [],
|
||||
verified_at: null,
|
||||
last_checked_at: null,
|
||||
}
|
||||
|
||||
describe('GET /inbox/domain', () => {
|
||||
const route = findRoute('GET', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 401 without context', async () => {
|
||||
const res = await route.handler(createMockRequest('/inbox/domain'), undefined)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the current domain row (or null)', async () => {
|
||||
getMock.mockResolvedValue(DOMAIN_ROW)
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const res = await route.handler(createMockRequest('/inbox/domain'), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof DOMAIN_ROW }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.domain).toBe('hansbolag.example')
|
||||
expect(getMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /inbox/domain', () => {
|
||||
const route = findRoute('POST', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for non-admin members', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(403)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the domain for admins', async () => {
|
||||
claimMock.mockResolvedValue({ ok: true, data: DOMAIN_ROW })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof DOMAIN_ROW }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('row-1')
|
||||
expect(claimMock).toHaveBeenCalledWith(expect.anything(), 'company-1', 'hansbolag.example')
|
||||
})
|
||||
|
||||
it('maps lib failures to their status codes', async () => {
|
||||
claimMock.mockResolvedValue({ ok: false, status: 409, error: 'Domänen är redan registrerad.' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects a missing domain field with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const request = createMockRequest('/inbox/domain', { method: 'POST', body: {} })
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(400)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks sandbox companies from claiming a domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } }) // company_members role check
|
||||
enqueue({ data: { is_sandbox: true } }) // company_settings sandbox check
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(403)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /inbox/domain/verify', () => {
|
||||
const route = findRoute('POST', '/inbox/domain/verify')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for viewers', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain/verify', { method: 'POST' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('re-checks verification for admins', async () => {
|
||||
verifyMock.mockResolvedValue({ ok: true, data: { ...DOMAIN_ROW, status: 'verified' } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain/verify', { method: 'POST' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('verified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /inbox/domain', () => {
|
||||
const route = findRoute('DELETE', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for non-admin members', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
expect(removeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the domain for owners', async () => {
|
||||
removeMock.mockResolvedValue({ ok: true, data: { removed: true } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { removed: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.removed).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces a Resend removal failure without deleting the row', async () => {
|
||||
removeMock.mockResolvedValue({ ok: false, status: 502, error: 'Kunde inte ta bort domänen.' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(502)
|
||||
})
|
||||
})
|
||||
|
||||
describe('INBOX_CUSTOM_DOMAINS_ENABLED gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete process.env.INBOX_CUSTOM_DOMAINS_ENABLED
|
||||
})
|
||||
|
||||
it('returns 403 FEATURE_DISABLED on every /inbox/domain route when the flag is off', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const routes: Array<[string, string]> = [
|
||||
['GET', '/inbox/domain'],
|
||||
['POST', '/inbox/domain'],
|
||||
['POST', '/inbox/domain/verify'],
|
||||
['DELETE', '/inbox/domain'],
|
||||
]
|
||||
for (const [method, path] of routes) {
|
||||
const res = await findRoute(method, path).handler(
|
||||
createMockRequest(path, { method }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ code: string }>(res)
|
||||
expect(status).toBe(403)
|
||||
expect(body.code).toBe('FEATURE_DISABLED')
|
||||
}
|
||||
expect(getMock).not.toHaveBeenCalled()
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
expect(verifyMock).not.toHaveBeenCalled()
|
||||
expect(removeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,558 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
normalizeInboundDomain,
|
||||
validateClaimableDomain,
|
||||
mapResendDomainStatus,
|
||||
resolveClaimedDomainStatus,
|
||||
claimCustomDomain,
|
||||
checkCustomDomainVerification,
|
||||
removeCustomDomain,
|
||||
findCompanyForRecipientDomains,
|
||||
applyDomainStatusFromWebhook,
|
||||
} from '@/extensions/general/invoice-inbox/lib/custom-domains'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const { domainsMock } = vi.hoisted(() => ({
|
||||
domainsMock: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('resend', () => ({
|
||||
Resend: class {
|
||||
domains = domainsMock
|
||||
},
|
||||
}))
|
||||
|
||||
const RECEIVING_RECORD = {
|
||||
record: 'Receiving',
|
||||
name: 'hansbolag.example',
|
||||
value: 'inbound.resend.example',
|
||||
type: 'MX',
|
||||
ttl: 'Auto',
|
||||
status: 'not_started',
|
||||
priority: 10,
|
||||
}
|
||||
|
||||
describe('normalizeInboundDomain', () => {
|
||||
it('lowercases and strips trailing dots', () => {
|
||||
expect(normalizeInboundDomain('Faktura.HansBolag.SE.')).toBe('faktura.hansbolag.se')
|
||||
})
|
||||
|
||||
it('accepts a pasted URL', () => {
|
||||
expect(normalizeInboundDomain('https://hansbolag.se/kontakt?x=1')).toBe('hansbolag.se')
|
||||
})
|
||||
|
||||
it('accepts a pasted email address', () => {
|
||||
expect(normalizeInboundDomain('faktura@hansbolag.se')).toBe('hansbolag.se')
|
||||
})
|
||||
|
||||
it('punycodes Swedish IDN domains', () => {
|
||||
const result = normalizeInboundDomain('blåbär.se')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.startsWith('xn--')).toBe(true)
|
||||
expect(result!.endsWith('.se')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostnames without a dot', () => {
|
||||
expect(normalizeInboundDomain('nodots')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(normalizeInboundDomain('')).toBeNull()
|
||||
expect(normalizeInboundDomain(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects IP addresses', () => {
|
||||
expect(normalizeInboundDomain('192.168.0.1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateClaimableDomain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.RESEND_INBOUND_DOMAIN = 'arcim.example'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.gnubok.example'
|
||||
})
|
||||
|
||||
it('blocks public mailbox providers', () => {
|
||||
expect(validateClaimableDomain('gmail.com')).not.toBeNull()
|
||||
expect(validateClaimableDomain('outlook.com')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('blocks the shared inbound domain and its subdomains', () => {
|
||||
expect(validateClaimableDomain('arcim.example')).not.toBeNull()
|
||||
expect(validateClaimableDomain('foo.arcim.example')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('blocks the app domain', () => {
|
||||
expect(validateClaimableDomain('app.gnubok.example')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('allows a normal customer domain', () => {
|
||||
expect(validateClaimableDomain('hansbolag.se')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapResendDomainStatus', () => {
|
||||
it('maps Resend statuses to our three buckets', () => {
|
||||
expect(mapResendDomainStatus('verified')).toBe('verified')
|
||||
// temporary_failure = previously verified, Resend still routing — keep routing.
|
||||
expect(mapResendDomainStatus('temporary_failure')).toBe('verified')
|
||||
expect(mapResendDomainStatus('failed')).toBe('failed')
|
||||
expect(mapResendDomainStatus('pending')).toBe('pending')
|
||||
expect(mapResendDomainStatus('not_started')).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveClaimedDomainStatus', () => {
|
||||
it('forces an adopted orphan to pending even when Resend reports verified', () => {
|
||||
// The security-critical case: adopting a domain freed by a deleted company
|
||||
// must NOT inherit a stale 'verified' — DNS control has to be re-proven.
|
||||
expect(resolveClaimedDomainStatus(true, 'verified')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(true, 'temporary_failure')).toBe('pending')
|
||||
})
|
||||
|
||||
it('maps Resend status normally for a freshly created (non-adopted) domain', () => {
|
||||
expect(resolveClaimedDomainStatus(false, 'pending')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(false, 'not_started')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(false, 'verified')).toBe('verified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('claimCustomDomain', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
process.env.RESEND_INBOUND_DOMAIN = 'arcim.example'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.gnubok.example'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
it('rejects an unparseable domain without touching the database', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'not a domain!')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(400)
|
||||
expect(domainsMock.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects blocked domains', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'gmail.com')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(400)
|
||||
})
|
||||
|
||||
it('registers the domain in Resend with receiving-only capabilities and stores the records', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'row-1',
|
||||
company_id: 'company-1',
|
||||
domain: 'hansbolag.example',
|
||||
status: 'pending',
|
||||
resend_domain_id: 'rd_1',
|
||||
dns_records: [RECEIVING_RECORD],
|
||||
},
|
||||
}) // update
|
||||
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: { id: 'rd_1', status: 'pending', records: [RECEIVING_RECORD] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'HansBolag.example')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.data.resend_domain_id).toBe('rd_1')
|
||||
expect(result.data.status).toBe('pending')
|
||||
}
|
||||
expect(domainsMock.create).toHaveBeenCalledWith({
|
||||
name: 'hansbolag.example',
|
||||
region: 'eu-west-1',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 409 when the company already has a domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_company_inbound_domains_company"' },
|
||||
})
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('redan en egen domän')
|
||||
}
|
||||
expect(domainsMock.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 when another company owns the domain, without leaking who', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_company_inbound_domains_domain"' },
|
||||
})
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toBe('Domänen är redan registrerad.')
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the row when Resend registration fails and no existing domain can be adopted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({ data: null }) // rollback delete
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'quota exceeded', statusCode: 422, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({ data: { data: [], has_more: false }, error: null })
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(502)
|
||||
expect(result.error).toContain('quota exceeded')
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts an orphaned receiving-only Resend domain when create says it already exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({
|
||||
data: { id: 'row-1', resend_domain_id: 'rd_9', status: 'pending', domain: 'hansbolag.example' },
|
||||
}) // update
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'domain already exists', statusCode: 409, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rd_9',
|
||||
name: 'HansBolag.example',
|
||||
status: 'pending',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: { id: 'rd_9', status: 'pending', records: [RECEIVING_RECORD] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.data.resend_domain_id).toBe('rd_9')
|
||||
})
|
||||
|
||||
it('refuses to adopt a sending domain — the platform outbound domain must never bind to a tenant', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({ data: null }) // rollback delete
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'domain already exists', statusCode: 409, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rd_prod_send',
|
||||
name: 'hansbolag.example',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('underdomän')
|
||||
}
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCustomDomainVerification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
it('returns 404 when the company has no custom domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(404)
|
||||
})
|
||||
|
||||
it('persists the verified status from Resend', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1', verified_at: null },
|
||||
}) // select
|
||||
enqueue({
|
||||
data: { id: 'row-1', status: 'verified', domain: 'hansbolag.example' },
|
||||
}) // update
|
||||
domainsMock.verify.mockResolvedValue({ data: { object: 'domain', id: 'rd_1' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [{ ...RECEIVING_RECORD, status: 'verified' }],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.data.status).toBe('verified')
|
||||
expect(domainsMock.verify).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('never verifies a domain whose receiving capability is disabled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_send', verified_at: null },
|
||||
}) // select — no update should follow
|
||||
domainsMock.verify.mockResolvedValue({ data: { object: 'domain', id: 'rd_send' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_send',
|
||||
status: 'verified', // verified for SENDING — must not count
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('mottagning')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCustomDomain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
const RECEIVING_ONLY_DOMAIN = {
|
||||
id: 'rd_1',
|
||||
status: 'pending',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [],
|
||||
}
|
||||
|
||||
it('removes the Resend domain and deletes the row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } }) // select
|
||||
enqueue({ data: null }) // delete
|
||||
domainsMock.get.mockResolvedValue({ data: RECEIVING_ONLY_DOMAIN, error: null })
|
||||
domainsMock.remove.mockResolvedValue({ data: { id: 'rd_1', deleted: true }, error: null })
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('tolerates a domain already gone from Resend', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } })
|
||||
enqueue({ data: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'not found', statusCode: 404, name: 'not_found' },
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the row when Resend removal fails — a verified orphan must never become adoptable', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } })
|
||||
domainsMock.get.mockResolvedValue({ data: RECEIVING_ONLY_DOMAIN, error: null })
|
||||
domainsMock.remove.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'internal error', statusCode: 500, name: 'application_error' },
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(502)
|
||||
})
|
||||
|
||||
it('deletes the row but never the Resend domain when it is not receiving-only', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Legacy row bound to the platform's outbound sending domain.
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_prod_send' } })
|
||||
enqueue({ data: null }) // row delete
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_prod_send',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findCompanyForRecipientDomains', () => {
|
||||
it('returns the first match in recipient order regardless of row order', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ company_id: 'c2', domain: 'second.example' },
|
||||
{ company_id: 'c1', domain: 'first.example' },
|
||||
],
|
||||
})
|
||||
const match = await findCompanyForRecipientDomains(
|
||||
supabase as unknown as SupabaseClient,
|
||||
['first.example', 'second.example'],
|
||||
)
|
||||
expect(match).toEqual({ companyId: 'c1', domain: 'first.example' })
|
||||
})
|
||||
|
||||
it('returns null when nothing matches', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
const match = await findCompanyForRecipientDomains(
|
||||
supabase as unknown as SupabaseClient,
|
||||
['unknown.example'],
|
||||
)
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null without querying when there are no recipient domains', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const match = await findCompanyForRecipientDomains(supabase as unknown as SupabaseClient, [])
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDomainStatusFromWebhook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
it('updates the matching row and reports true when receiving is confirmed', async () => {
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [RECEIVING_RECORD],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
records: [],
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('never flips a sending-only domain to verified off the event status', async () => {
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update (last_checked_at only)
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
records: [],
|
||||
})
|
||||
// Row matched, but the verified transition was refused.
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('keeps stored status when the capability lookup fails', async () => {
|
||||
domainsMock.get.mockResolvedValue({ data: null, error: { message: 'boom' } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update (last_checked_at only)
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the capability lookup for non-verified statuses', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'failed',
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports false for unknown Resend domain ids', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_unknown',
|
||||
status: 'verified',
|
||||
})
|
||||
expect(matched).toBe(false)
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,19 @@ vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
// applyDomainStatusFromWebhook confirms the receiving capability with Resend
|
||||
// before flipping a row to verified — keep that lookup off the network.
|
||||
const { domainsMock } = vi.hoisted(() => ({
|
||||
domainsMock: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('resend', () => ({
|
||||
Resend: class {
|
||||
domains = domainsMock
|
||||
},
|
||||
}))
|
||||
|
||||
// Rate limiter is a thin RPC wrapper; bypass it so the queued-mock sequence
|
||||
// in each test doesn't have to account for the extra Supabase call.
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
@@ -103,15 +116,142 @@ describe('POST /inbound', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 when no recipient matches our domain', async () => {
|
||||
it('returns 404 when no recipient matches our domain or a verified custom domain', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['random@contoso.com'] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // company_inbound_domains lookup finds nothing
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('routes mail on a verified custom domain to its company (any local part)', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['fakturor@hansbolag.example'], attachments: [] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain
|
||||
enqueue({ data: { created_by: 'user-owner-9' } }) // company owner
|
||||
enqueue({ data: null }) // no-attachments error-row insert
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
||||
object: 'email',
|
||||
id: 'em_123',
|
||||
to: ['fakturor@hansbolag.example'],
|
||||
from: 'billing@supplier.com',
|
||||
created_at: '2026-04-20T10:00:00Z',
|
||||
subject: 'Invoice #5678',
|
||||
bcc: null,
|
||||
cc: null,
|
||||
reply_to: null,
|
||||
html: null,
|
||||
text: 'Body',
|
||||
headers: {},
|
||||
message_id: '<msg@x>',
|
||||
raw: null,
|
||||
attachments: [],
|
||||
} as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.reason).toBe('no_attachments')
|
||||
})
|
||||
|
||||
it('does not route mail for an unverified custom domain', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['faktura@pending-bolag.example'] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// status='verified' filter means a pending claim never matches
|
||||
enqueue({ data: [] })
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('prefers the shared-domain address when both shared and custom recipients are present', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({
|
||||
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
||||
attachments: [],
|
||||
}) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Only the three shared-path queries are enqueued — if the handler also
|
||||
// ran the custom-domain lookup, the queue would shift and created_by
|
||||
// would resolve to null (500). A 200 proves the shared path won.
|
||||
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
||||
enqueue({ data: { created_by: 'user-owner-1' } })
|
||||
enqueue({ data: null }) // no-attachments error-row insert
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
||||
object: 'email',
|
||||
id: 'em_123',
|
||||
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
||||
from: 'billing@supplier.com',
|
||||
created_at: '2026-04-20T10:00:00Z',
|
||||
subject: 'Invoice #5678',
|
||||
bcc: null,
|
||||
cc: null,
|
||||
reply_to: null,
|
||||
html: null,
|
||||
text: 'Body',
|
||||
headers: {},
|
||||
message_id: '<msg@x>',
|
||||
raw: null,
|
||||
attachments: [],
|
||||
} as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.reason).toBe('no_attachments')
|
||||
})
|
||||
|
||||
it('applies domain.updated events to custom-domain rows', async () => {
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_123',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
||||
type: 'domain.updated',
|
||||
created_at: '2026-07-01T10:00:00Z',
|
||||
data: {
|
||||
id: 'rd_123',
|
||||
name: 'hansbolag.example',
|
||||
status: 'verified',
|
||||
created_at: '2026-07-01T09:00:00Z',
|
||||
region: 'eu-west-1',
|
||||
records: [],
|
||||
},
|
||||
} as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } }) // row by resend_domain_id
|
||||
enqueue({ data: null }) // update
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.domain_updated).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 404 when the address is not in company_inboxes', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractLocalPartForDomain } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
||||
import { extractLocalPartForDomain, parseRecipients } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
||||
|
||||
describe('extractLocalPartForDomain', () => {
|
||||
it('returns the local part when a recipient matches the domain', () => {
|
||||
@@ -50,3 +50,24 @@ describe('extractLocalPartForDomain', () => {
|
||||
expect(result).toBe('acme-xxx')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRecipients', () => {
|
||||
it('splits recipients into lowercased localPart/domain pairs in order', () => {
|
||||
expect(
|
||||
parseRecipients(['Faktura@HansBolag.SE', 'billing@acme.se'])
|
||||
).toEqual([
|
||||
{ localPart: 'faktura', domain: 'hansbolag.se' },
|
||||
{ localPart: 'billing', domain: 'acme.se' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips malformed addresses', () => {
|
||||
expect(parseRecipients(['not-an-email', '@x.se', 'foo@', 'ok@a.se'])).toEqual([
|
||||
{ localPart: 'ok', domain: 'a.se' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for no recipients', () => {
|
||||
expect(parseRecipients([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchReceivingEmail,
|
||||
fetchInboundAttachment,
|
||||
extractLocalPartForDomain,
|
||||
parseRecipients,
|
||||
isEmailReceivedEvent,
|
||||
ResendSignatureError,
|
||||
} from './lib/resend-inbound'
|
||||
@@ -17,6 +18,14 @@ import {
|
||||
getActiveInbox,
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import {
|
||||
claimCustomDomain,
|
||||
checkCustomDomainVerification,
|
||||
removeCustomDomain,
|
||||
getCustomDomain,
|
||||
findCompanyForRecipientDomains,
|
||||
applyDomainStatusFromWebhook,
|
||||
} from './lib/custom-domains'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
@@ -143,6 +152,27 @@ const UpdateExtractedDataSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// Claim body for POST /inbox/domain. Length-capped only — real validation
|
||||
// (punycode, hostname shape, blocklist) lives in normalizeInboundDomain /
|
||||
// validateClaimableDomain so the same rules apply to every caller.
|
||||
const ClaimDomainSchema = z.object({
|
||||
domain: z.string().trim().min(1).max(255),
|
||||
})
|
||||
|
||||
// Custom inbound domains are fully built but deliberately not exposed —
|
||||
// product decision 2026-07-02: the default is the Fortnox-style shared
|
||||
// address (+ user-side forwarding); own-domain inbound waits for real demand.
|
||||
// Flip INBOX_CUSTOM_DOMAINS_ENABLED=true to re-enable the /inbox/domain
|
||||
// routes. The globe entry point in InvoiceInboxWorkspace was removed at the
|
||||
// same time — restore it when re-enabling.
|
||||
const customDomainsEnabled = () => process.env.INBOX_CUSTOM_DOMAINS_ENABLED === 'true'
|
||||
|
||||
const customDomainsDisabledResponse = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Egen domän är inte tillgänglig.', code: 'FEATURE_DISABLED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
|
||||
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
@@ -1288,6 +1318,123 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: read current state ────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/inbox/domain',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
try {
|
||||
const row = await getCustomDomain(ctx.supabase, ctx.companyId)
|
||||
// null when the company has no custom domain — the UI renders the
|
||||
// claim form in that case.
|
||||
return NextResponse.json({ data: row })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load domain' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: claim (admin/owner only) ──────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/inbox/domain',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
// Sandbox companies are anonymous 24h demo accounts — letting them
|
||||
// register domains in our Resend account is a pure abuse vector.
|
||||
if (await isSandboxCompany(ctx.supabase, ctx.companyId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Egen domän är inte tillgänglig i sandlådan.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Claiming hits the Resend domains API — share the per-company inbox
|
||||
// quota so a claim/delete loop can't burn the provider budget.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'För många förfrågningar — försök igen om en stund.', retry_after: limit.retryAfterSec },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
let body: z.infer<typeof ClaimDomainSchema>
|
||||
try {
|
||||
body = ClaimDomainSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await claimCustomDomain(ctx.supabase, ctx.companyId, body.domain)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: re-check verification ─────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/inbox/domain/verify',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
// verify() triggers a DNS check at Resend — rate-limit the button.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'För många kontroller — försök igen om en stund.', retry_after: limit.retryAfterSec },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await checkCustomDomainVerification(ctx.supabase, ctx.companyId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: remove (admin/owner only) ─────
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/inbox/domain',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
const result = await removeCustomDomain(ctx.supabase, ctx.companyId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Resend Inbound webhook (Svix-signed, no user auth) ──
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1313,44 +1460,87 @@ export const invoiceInboxExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Resend pushes domain.* lifecycle events to the same webhook. Apply
|
||||
// domain.updated to custom-domain rows so verification flips without
|
||||
// the user pressing "Kontrollera igen" (requires the event type to be
|
||||
// subscribed on the Resend webhook; harmless when it isn't).
|
||||
if (event.type === 'domain.updated') {
|
||||
const domainServiceSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
const matched = await applyDomainStatusFromWebhook(domainServiceSupabase, {
|
||||
id: event.data.id,
|
||||
status: event.data.status,
|
||||
records: event.data.records,
|
||||
})
|
||||
return NextResponse.json({ data: { domain_updated: matched } })
|
||||
}
|
||||
|
||||
if (!isEmailReceivedEvent(event)) {
|
||||
return NextResponse.json({ data: { ignored: event.type } }, { status: 200 })
|
||||
}
|
||||
|
||||
const { email_id, to, from, subject, message_id, created_at } = event.data
|
||||
|
||||
const localPart = extractLocalPartForDomain(to, domain)
|
||||
if (!localPart) {
|
||||
console.warn('[invoice-inbox/inbound] No recipient matched domain', { to, domain })
|
||||
return NextResponse.json({ error: 'No matching recipient' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serviceSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const { data: inbox } = await serviceSupabase
|
||||
.from('company_inboxes')
|
||||
.select('id, company_id, status')
|
||||
.eq('local_part', localPart)
|
||||
.maybeSingle()
|
||||
// Recipient → company resolution. Shared-domain addresses first
|
||||
// (existing local_part flow), then per-company verified custom
|
||||
// domains. Custom domains are catch-all by design: MX routing is
|
||||
// per-domain, and a supplier typing fakturor@ instead of faktura@
|
||||
// must land in the inbox rather than silently vanish (Resend has
|
||||
// already accepted the message; there is no bounce path).
|
||||
let companyId: string | null = null
|
||||
let sharedInboxStatus: string | null = null
|
||||
|
||||
if (!inbox) {
|
||||
return NextResponse.json({ error: 'Address not found' }, { status: 404 })
|
||||
const localPart = extractLocalPartForDomain(to, domain)
|
||||
if (localPart) {
|
||||
const { data: inbox } = await serviceSupabase
|
||||
.from('company_inboxes')
|
||||
.select('id, company_id, status')
|
||||
.eq('local_part', localPart)
|
||||
.maybeSingle()
|
||||
if (inbox) {
|
||||
sharedInboxStatus = inbox.status
|
||||
if (inbox.status === 'active') companyId = inbox.company_id
|
||||
}
|
||||
}
|
||||
if (inbox.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Address no longer active' }, { status: 410 })
|
||||
|
||||
if (!companyId) {
|
||||
const customDomains = parseRecipients(to)
|
||||
.map((r) => r.domain)
|
||||
.filter((d) => d !== domain.toLowerCase())
|
||||
if (customDomains.length > 0) {
|
||||
const match = await findCompanyForRecipientDomains(serviceSupabase, customDomains)
|
||||
if (match) companyId = match.companyId
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
// Preserve the pre-custom-domain status semantics: 410 for a
|
||||
// deprecated/blocked shared address, 404 otherwise.
|
||||
if (sharedInboxStatus && sharedInboxStatus !== 'active') {
|
||||
return NextResponse.json({ error: 'Address no longer active' }, { status: 410 })
|
||||
}
|
||||
console.warn('[invoice-inbox/inbound] No recipient matched', { to, domain })
|
||||
return NextResponse.json(
|
||||
{ error: localPart ? 'Address not found' : 'No matching recipient' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: company } = await serviceSupabase
|
||||
.from('companies')
|
||||
.select('created_by')
|
||||
.eq('id', inbox.company_id)
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company?.created_by) {
|
||||
console.error('[invoice-inbox/inbound] Company has no created_by', inbox.company_id)
|
||||
console.error('[invoice-inbox/inbound] Company has no created_by', companyId)
|
||||
return NextResponse.json({ error: 'Company owner missing' }, { status: 500 })
|
||||
}
|
||||
const userId = company.created_by
|
||||
@@ -1370,11 +1560,11 @@ export const invoiceInboxExtension: Extension = {
|
||||
// Per-company rate limit (30/min, 500/day). Same Postgres-backed
|
||||
// RPC as /upload. Acknowledge + drop on cap — returning 429 to
|
||||
// Resend would just consume more budget via their retry.
|
||||
const limit = await checkInboxUploadRateLimit(serviceSupabase, inbox.company_id)
|
||||
const limit = await checkInboxUploadRateLimit(serviceSupabase, companyId)
|
||||
if (!limit.ok) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
companyId,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
@@ -1404,7 +1594,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
if (truncatedCount > 0) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
companyId,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
@@ -1426,7 +1616,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||||
company_id: inbox.company_id,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
@@ -1459,7 +1649,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
// oversized values when read back into the UI / audit trails.
|
||||
try {
|
||||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||||
company_id: inbox.company_id,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
@@ -1530,7 +1720,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
const innerResult = await uploadAndExtract(
|
||||
serviceSupabase,
|
||||
userId,
|
||||
inbox.company_id,
|
||||
companyId,
|
||||
{ name: innerName, buffer: innerArrayBuffer, type: innerType },
|
||||
'email',
|
||||
{
|
||||
@@ -1562,7 +1752,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
const result = await uploadAndExtract(
|
||||
serviceSupabase,
|
||||
userId,
|
||||
inbox.company_id,
|
||||
companyId,
|
||||
{ name: download.filename, buffer: download.buffer, type: download.contentType },
|
||||
'email',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import { Resend } from 'resend'
|
||||
import type { Domain, DomainStatus } from 'resend'
|
||||
import { domainToASCII } from 'node:url'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CompanyInboundDomain, CompanyInboundDomainStatus } from '@/types'
|
||||
|
||||
function getResend(): Resend {
|
||||
const apiKey = process.env.RESEND_API_KEY
|
||||
if (!apiKey) throw new Error('RESEND_API_KEY is required')
|
||||
return new Resend(apiKey)
|
||||
}
|
||||
|
||||
export type CustomDomainResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
// Public mailbox providers a company can never own. DNS verification is the
|
||||
// real ownership gate — this list only exists to fail fast with a clear
|
||||
// message instead of a claim that can never verify.
|
||||
const PUBLIC_EMAIL_DOMAINS = new Set([
|
||||
'gmail.com',
|
||||
'googlemail.com',
|
||||
'outlook.com',
|
||||
'hotmail.com',
|
||||
'hotmail.se',
|
||||
'live.com',
|
||||
'live.se',
|
||||
'msn.com',
|
||||
'icloud.com',
|
||||
'me.com',
|
||||
'mac.com',
|
||||
'yahoo.com',
|
||||
'ymail.com',
|
||||
'protonmail.com',
|
||||
'proton.me',
|
||||
'fastmail.com',
|
||||
'gmx.com',
|
||||
'telia.com',
|
||||
'comhem.se',
|
||||
'spray.se',
|
||||
'passagen.se',
|
||||
])
|
||||
|
||||
// Accepts what users actually paste — "Faktura.Hansbolag.SE.", a full URL, or
|
||||
// an email address — and reduces it to a lowercased, punycoded hostname.
|
||||
// Returns null when no valid hostname can be extracted.
|
||||
export function normalizeInboundDomain(raw: string): string | null {
|
||||
let value = String(raw ?? '').trim().toLowerCase()
|
||||
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // strip scheme
|
||||
value = value.split('/')[0].split('?')[0]
|
||||
const atIndex = value.lastIndexOf('@')
|
||||
if (atIndex !== -1) value = value.slice(atIndex + 1)
|
||||
value = value.replace(/^\.+|\.+$/g, '')
|
||||
if (!value) return null
|
||||
|
||||
// IDN → punycode (blåbär.se → xn--blbr-noab.se). Returns '' when the input
|
||||
// is not a valid domain.
|
||||
const ascii = domainToASCII(value)
|
||||
if (!ascii) return null
|
||||
|
||||
return isValidHostname(ascii) ? ascii : null
|
||||
}
|
||||
|
||||
function isValidHostname(domain: string): boolean {
|
||||
if (domain.length < 4 || domain.length > 253) return false
|
||||
const labels = domain.split('.')
|
||||
if (labels.length < 2) return false
|
||||
if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return false
|
||||
// TLD must contain a letter — rejects IP addresses and all-numeric TLDs.
|
||||
return /[a-z]/.test(labels[labels.length - 1])
|
||||
}
|
||||
|
||||
// Returns a Swedish error message when the domain must not be claimed, or
|
||||
// null when it is claimable. Blocks public mailbox providers, the shared
|
||||
// inbound domain (and its subdomains), and the app's own domain.
|
||||
export function validateClaimableDomain(domain: string): string | null {
|
||||
if (PUBLIC_EMAIL_DOMAINS.has(domain)) {
|
||||
return 'Publika e-postdomäner (t.ex. Gmail, Outlook) kan inte användas. Ange en domän som bolaget äger.'
|
||||
}
|
||||
|
||||
const reserved: string[] = []
|
||||
const shared = process.env.RESEND_INBOUND_DOMAIN?.toLowerCase()
|
||||
if (shared) reserved.push(shared)
|
||||
try {
|
||||
const appHost = new URL(process.env.NEXT_PUBLIC_APP_URL ?? '').hostname.toLowerCase()
|
||||
if (appHost) reserved.push(appHost)
|
||||
} catch {
|
||||
// No parseable app URL — nothing extra to reserve.
|
||||
}
|
||||
|
||||
for (const r of reserved) {
|
||||
if (domain === r || domain.endsWith(`.${r}`)) {
|
||||
return 'Den här domänen är reserverad och kan inte användas som egen domän.'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// `temporary_failure` is a runtime status the Resend API can still return but
|
||||
// which the SDK's DomainStatus type dropped in 6.16.0 — accept it explicitly.
|
||||
export function mapResendDomainStatus(
|
||||
status: DomainStatus | 'temporary_failure',
|
||||
): CompanyInboundDomainStatus {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'verified'
|
||||
// temporary_failure = a previously verified domain failed a DNS re-check;
|
||||
// Resend keeps the domain active while it retries (~72h). Keep routing
|
||||
// rather than silently dropping the customer's invoices on a DNS blip.
|
||||
case 'temporary_failure':
|
||||
return 'verified'
|
||||
case 'failed':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'pending' // 'pending' | 'not_started'
|
||||
}
|
||||
}
|
||||
|
||||
// Status to persist when a claim first registers OR adopts a domain in Resend.
|
||||
// A freshly-created domain has no DNS yet, so it is always unverified. An
|
||||
// ADOPTED orphan (left by a crashed earlier claim, or freed by a deleted
|
||||
// company whose Resend domain was never cleaned up) may still report 'verified'
|
||||
// from a previous owner — NEVER inherit that. Force the new claimant to re-prove
|
||||
// DNS control via checkCustomDomainVerification() before any mail routes;
|
||||
// otherwise a different tenant could re-claim a freed domain and silently
|
||||
// inherit routing for mail the original owner's MX still delegates to Resend.
|
||||
// Kept pure so the security rule is unit-testable.
|
||||
export function resolveClaimedDomainStatus(
|
||||
wasAdopted: boolean,
|
||||
resendStatus: DomainStatus | 'temporary_failure',
|
||||
): CompanyInboundDomainStatus {
|
||||
return wasAdopted ? 'pending' : mapResendDomainStatus(resendStatus)
|
||||
}
|
||||
|
||||
// Only domains this feature created (receiving-only) may ever be adopted,
|
||||
// verified, or deleted from Resend here. The same Resend account also holds
|
||||
// the platform's OUTBOUND domains (e.g. the invoiceservice@ sender): adopting
|
||||
// one binds a tenant row to production sending infrastructure, and removing
|
||||
// one would kill outbound mail for every customer.
|
||||
export function isReceivingOnlyProfile(
|
||||
capabilities: { sending?: string; receiving?: string } | null | undefined
|
||||
): boolean {
|
||||
return capabilities?.receiving === 'enabled' && capabilities?.sending === 'disabled'
|
||||
}
|
||||
|
||||
export async function getCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CompanyInboundDomain | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error(`Failed to load custom domain: ${error.message}`)
|
||||
return (data as CompanyInboundDomain | null) ?? null
|
||||
}
|
||||
|
||||
// A claim that crashed between the Resend create and the row update leaves an
|
||||
// orphan domain in our Resend account. On the next claim attempt the create
|
||||
// fails ("already exists") — adopt the existing Resend domain instead of
|
||||
// dead-ending the user. Safe because removeCustomDomain() never deletes the
|
||||
// DB row while the domain still exists in Resend, so an adoptable domain can
|
||||
// never belong to another live company_inbound_domains row.
|
||||
async function findExistingResendDomain(resend: Resend, domain: string): Promise<Domain | null> {
|
||||
let after: string | undefined
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const { data, error } = await resend.domains.list(
|
||||
after ? { limit: 100, after } : { limit: 100 }
|
||||
)
|
||||
if (error || !data) return null
|
||||
// `domain` is already normalized (lowercased, punycoded) — run Resend's
|
||||
// name through the same normalization so an IDN stored in unicode form
|
||||
// still matches.
|
||||
const hit = data.data.find((d) => (normalizeInboundDomain(d.name) ?? d.name.toLowerCase()) === domain)
|
||||
if (hit) return hit
|
||||
if (!data.has_more || data.data.length === 0) return null
|
||||
after = data.data[data.data.length - 1].id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Claim a custom inbound domain for the company: insert the row, register the
|
||||
// domain in our Resend account with the receiving capability, and store the
|
||||
// DNS records the user must publish. The DB insert goes first so the unique
|
||||
// indexes ((lower(domain)) and (company_id)) serialize concurrent claims
|
||||
// before we ever talk to Resend.
|
||||
export async function claimCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
rawDomain: string
|
||||
): Promise<CustomDomainResult<CompanyInboundDomain>> {
|
||||
const domain = normalizeInboundDomain(rawDomain)
|
||||
if (!domain) {
|
||||
return { ok: false, status: 400, error: 'Ogiltig domän. Ange t.ex. faktura.dittbolag.se.' }
|
||||
}
|
||||
const blocked = validateClaimableDomain(domain)
|
||||
if (blocked) return { ok: false, status: 400, error: blocked }
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.insert({ company_id: companyId, domain, status: 'pending' })
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (insertError || !inserted) {
|
||||
if (insertError?.code === '23505') {
|
||||
const message = insertError.message.includes('idx_company_inbound_domains_company')
|
||||
? 'Bolaget har redan en egen domän. Ta bort den innan du lägger till en ny.'
|
||||
: 'Domänen är redan registrerad.'
|
||||
return { ok: false, status: 409, error: message }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: insertError?.message ?? 'Kunde inte spara domänen.',
|
||||
}
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
await supabase
|
||||
.from('company_inbound_domains')
|
||||
.delete()
|
||||
.eq('id', inserted.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = getResend()
|
||||
|
||||
// Receiving only — we never send from the customer's domain, and skipping
|
||||
// the sending capability keeps the DNS record list minimal.
|
||||
let resendDomainId: string
|
||||
let wasAdopted = false
|
||||
const created = await resend.domains.create({
|
||||
name: domain,
|
||||
region: 'eu-west-1',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
})
|
||||
|
||||
if (created.error || !created.data) {
|
||||
const adopted = await findExistingResendDomain(resend, domain)
|
||||
if (!adopted) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte registrera domänen hos e-postleverantören: ${created.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
// Never adopt a domain this feature didn't create — e.g. the platform's
|
||||
// own outbound (sending) domains, which live in the same Resend account.
|
||||
if (!isReceivingOnlyProfile(adopted.capabilities)) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error:
|
||||
'Domänen används redan för e-postutskick i plattformen och kan inte användas som inkorgsdomän. Använd en underdomän i stället.',
|
||||
}
|
||||
}
|
||||
resendDomainId = adopted.id
|
||||
wasAdopted = true
|
||||
} else {
|
||||
resendDomainId = created.data.id
|
||||
}
|
||||
|
||||
// get() rather than the create response: on the adoption path we have no
|
||||
// records yet, and get() returns the same shape either way.
|
||||
const fetched = await resend.domains.get(resendDomainId)
|
||||
if (fetched.error || !fetched.data) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte hämta DNS-poster: ${fetched.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Adopted orphans never inherit 'verified' — see resolveClaimedDomainStatus.
|
||||
const status = resolveClaimedDomainStatus(wasAdopted, fetched.data.status)
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
resend_domain_id: resendDomainId,
|
||||
dns_records: fetched.data.records,
|
||||
status,
|
||||
verified_at: status === 'verified' ? new Date().toISOString() : null,
|
||||
last_checked_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', inserted.id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
await rollback()
|
||||
return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara DNS-poster.' }
|
||||
}
|
||||
|
||||
return { ok: true, data: updated as CompanyInboundDomain }
|
||||
} catch (err) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Domänregistreringen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check verification with Resend and persist the outcome. verify() kicks
|
||||
// off Resend's DNS check; get() reads the (possibly updated) status and the
|
||||
// per-record state shown in the UI.
|
||||
export async function checkCustomDomainVerification(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CustomDomainResult<CompanyInboundDomain>> {
|
||||
const row = await getCustomDomain(supabase, companyId)
|
||||
if (!row) return { ok: false, status: 404, error: 'Ingen egen domän är registrerad.' }
|
||||
if (!row.resend_domain_id) {
|
||||
return { ok: false, status: 409, error: 'Domänen saknar koppling till e-postleverantören. Ta bort den och lägg till den igen.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = getResend()
|
||||
await resend.domains.verify(row.resend_domain_id)
|
||||
const fetched = await resend.domains.get(row.resend_domain_id)
|
||||
if (fetched.error || !fetched.data) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte kontrollera domänen: ${fetched.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// A domain without the receiving capability can never take inbound mail —
|
||||
// Resend's 'verified' there only reflects sending records. Fail loudly
|
||||
// instead of ever flipping such a row to verified (guards legacy rows
|
||||
// bound to a sending domain before the adopt-profile check existed).
|
||||
if (fetched.data.capabilities?.receiving !== 'enabled') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error:
|
||||
'Domänen är inte konfigurerad för mottagning hos e-postleverantören. Ta bort den och använd en underdomän i stället.',
|
||||
}
|
||||
}
|
||||
|
||||
const status = mapResendDomainStatus(fetched.data.status)
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
status,
|
||||
dns_records: fetched.data.records,
|
||||
last_checked_at: new Date().toISOString(),
|
||||
verified_at: status === 'verified' ? (row.verified_at ?? new Date().toISOString()) : row.verified_at,
|
||||
})
|
||||
.eq('id', row.id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara status.' }
|
||||
}
|
||||
return { ok: true, data: updated as CompanyInboundDomain }
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Kontrollen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the custom domain: delete it from Resend first, then the row.
|
||||
// Order matters — the row is only deleted once the domain is confirmed gone
|
||||
// from Resend (or was never there). Deleting the row while a verified domain
|
||||
// lingers in our Resend account would let a later claim adopt a domain whose
|
||||
// MX still receives someone else's mail.
|
||||
export async function removeCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CustomDomainResult<{ removed: true }>> {
|
||||
const row = await getCustomDomain(supabase, companyId)
|
||||
if (!row) return { ok: false, status: 404, error: 'Ingen egen domän är registrerad.' }
|
||||
|
||||
if (row.resend_domain_id) {
|
||||
try {
|
||||
const resend = getResend()
|
||||
// Only delete Resend domains this feature created (receiving-only). A
|
||||
// row bound to anything else (pre-guard legacy, e.g. an outbound
|
||||
// sending domain) must never take production infrastructure down with
|
||||
// it — skip the Resend removal and just drop the row. Safe, because
|
||||
// the adopt path refuses non-receiving-only domains, so the leftover
|
||||
// Resend domain is not adoptable by another company.
|
||||
const fetched = await resend.domains.get(row.resend_domain_id)
|
||||
if (fetched.error && fetched.error.statusCode !== 404) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte kontrollera domänen hos e-postleverantören: ${fetched.error.message}`,
|
||||
}
|
||||
}
|
||||
if (fetched.data && isReceivingOnlyProfile(fetched.data.capabilities)) {
|
||||
const removed = await resend.domains.remove(row.resend_domain_id)
|
||||
if (removed.error && removed.error.statusCode !== 404) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte ta bort domänen hos e-postleverantören: ${removed.error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Borttagningen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.delete()
|
||||
.eq('id', row.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (deleteError) return { ok: false, status: 500, error: deleteError.message }
|
||||
return { ok: true, data: { removed: true } }
|
||||
}
|
||||
|
||||
// Webhook-side lookup: given the recipient domains of an inbound email,
|
||||
// return the owning company for the first verified match (recipient order
|
||||
// preserved). Catch-all by design — any local part on a verified domain
|
||||
// routes to the company, so a supplier typing fakturor@ instead of faktura@
|
||||
// still lands instead of silently vanishing (Resend has already accepted the
|
||||
// message at SMTP; there is no bounce path).
|
||||
export async function findCompanyForRecipientDomains(
|
||||
supabase: SupabaseClient,
|
||||
recipientDomains: string[]
|
||||
): Promise<{ companyId: string; domain: string } | null> {
|
||||
const unique = [...new Set(recipientDomains.map((d) => d.toLowerCase()))]
|
||||
if (unique.length === 0) return null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('company_id, domain')
|
||||
.in('domain', unique)
|
||||
.eq('status', 'verified')
|
||||
|
||||
if (error || !data || data.length === 0) return null
|
||||
|
||||
const byDomain = new Map(
|
||||
(data as Array<{ company_id: string; domain: string }>).map((r) => [r.domain.toLowerCase(), r])
|
||||
)
|
||||
for (const d of unique) {
|
||||
const hit = byDomain.get(d)
|
||||
if (hit) return { companyId: hit.company_id, domain: hit.domain }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Applies a Resend `domain.updated` webhook event so verification flips
|
||||
// without the user pressing "Kontrollera igen". No-op when the domain id is
|
||||
// unknown (e.g. the account's sending domains). Returns whether a row matched.
|
||||
export async function applyDomainStatusFromWebhook(
|
||||
supabase: SupabaseClient,
|
||||
event: { id: string; status: string; records?: unknown }
|
||||
): Promise<boolean> {
|
||||
const { data: row } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('id, verified_at')
|
||||
.eq('resend_domain_id', event.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!row) return false
|
||||
|
||||
const status = mapResendDomainStatus(event.status as DomainStatus)
|
||||
|
||||
// The event's 'verified' is the domain's mixed sending/receiving status —
|
||||
// it carries no capability breakdown, so it can reflect sending-only
|
||||
// records. Mirror checkCustomDomainVerification: confirm the receiving
|
||||
// capability with Resend before ever flipping a row to verified. On a
|
||||
// failed lookup or a sending-only profile, keep the stored status (the
|
||||
// manual "Kontrollera igen" path remains available) — fail closed, never
|
||||
// route inbound mail off an unproven capability.
|
||||
if (status === 'verified') {
|
||||
let receivingConfirmed = false
|
||||
try {
|
||||
const fetched = await getResend().domains.get(event.id)
|
||||
receivingConfirmed =
|
||||
!fetched.error && fetched.data?.capabilities?.receiving === 'enabled'
|
||||
} catch {
|
||||
receivingConfirmed = false
|
||||
}
|
||||
if (!receivingConfirmed) {
|
||||
const { error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
...(event.records !== undefined ? { dns_records: event.records } : {}),
|
||||
last_checked_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', (row as { id: string }).id)
|
||||
return !error
|
||||
}
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
status,
|
||||
...(event.records !== undefined ? { dns_records: event.records } : {}),
|
||||
last_checked_at: new Date().toISOString(),
|
||||
verified_at:
|
||||
status === 'verified'
|
||||
? ((row as { verified_at: string | null }).verified_at ?? new Date().toISOString())
|
||||
: (row as { verified_at: string | null }).verified_at,
|
||||
})
|
||||
.eq('id', (row as { id: string }).id)
|
||||
|
||||
return !error
|
||||
}
|
||||
@@ -94,6 +94,19 @@ export function extractLocalPartForDomain(recipients: string[], domain: string):
|
||||
return null
|
||||
}
|
||||
|
||||
// Splits every parseable recipient into { localPart, domain }, lowercased and
|
||||
// in original order. Used to match recipients against per-company verified
|
||||
// custom domains when none of them is on the shared inbound domain.
|
||||
export function parseRecipients(recipients: string[]): Array<{ localPart: string; domain: string }> {
|
||||
const parsed: Array<{ localPart: string; domain: string }> = []
|
||||
for (const addr of recipients) {
|
||||
const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/)
|
||||
if (!match) continue
|
||||
parsed.push({ localPart: match[1].toLowerCase(), domain: match[2].toLowerCase() })
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function isEmailReceivedEvent(event: WebhookEventPayload): event is EmailReceivedEvent {
|
||||
return event.type === 'email.received'
|
||||
}
|
||||
|
||||
@@ -85,9 +85,16 @@ describe('tools/list payload size guard', () => {
|
||||
// already use the compact "Dims bag" form (~90 tokens trimmed first);
|
||||
// the remainder is schema structure the resolve-don't-select contract
|
||||
// depends on, not trimmable prose.
|
||||
// * 44K → 45K when the rot/rut branch merged with main: main's #877 put
|
||||
// qualified identifiers in all tool output schemas (+~260 across 103
|
||||
// tools — wire contract, not prose) and the branch added
|
||||
// gnubok_generate_rot_rut_file (~444: begäran-om-utbetalning file flow,
|
||||
// eligible/blocked per-invoice output). Each side alone was under the
|
||||
// ceiling; the combination crossed it by ~220. Descriptions are at
|
||||
// their trimmed floor per the entries above.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(44_000)
|
||||
expect(approxTokens).toBeLessThan(45_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for the test-mode API-key write guard in the MCP dispatcher.
|
||||
*
|
||||
* A `gnubok_sk_test_` key is bound to the REAL active company. On the v1 REST
|
||||
* surface every write is forced to dry-run (or blocked when it can't be
|
||||
* simulated). The MCP tools/call path must mirror that: a write tool that
|
||||
* cannot be simulated is refused BEFORE execute() so a test key can never stage
|
||||
* a real pending_operation (and, with the approve scope, commit it). Read tools
|
||||
* pass through unchanged.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
}
|
||||
return () => chain
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
// A TEST-mode key holding the approve scope, so the scope gate passes and
|
||||
// the test-key write guard is what we exercise.
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['pending_operations:approve', 'reports:read'],
|
||||
apiKeyId: 'key-test-1',
|
||||
apiKeyName: 'Test Key',
|
||||
mode: 'test',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function mcpToolCall(name: string, args: Record<string, unknown> = {}): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
|
||||
})
|
||||
}
|
||||
|
||||
interface ToolCalledEvent {
|
||||
tool: string
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorKind: string | null
|
||||
latencyMs: number
|
||||
}
|
||||
|
||||
function captureNextToolCalled(): Promise<ToolCalledEvent> {
|
||||
return new Promise((resolve) => {
|
||||
const off = eventBus.on('mcp.tool_called', (payload) => {
|
||||
off()
|
||||
resolve(payload as unknown as ToolCalledEvent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function parsedToolResult(response: Response): Promise<{ isError: boolean; payload: Record<string, unknown> }> {
|
||||
const json = await response.json()
|
||||
const result = json.result as { isError?: boolean; content: { text: string }[] }
|
||||
return { isError: result.isError === true, payload: JSON.parse(result.content[0].text) }
|
||||
}
|
||||
|
||||
describe('MCP test-key write guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('blocks a non-simulatable write tool for a test-mode key — before execute()', async () => {
|
||||
const eventPromise = captureNextToolCalled()
|
||||
|
||||
// approve has readOnlyHint:false and no dry_run param → cannot be simulated.
|
||||
const response = await handleMcpRequest(
|
||||
mcpToolCall('gnubok_approve_pending_operation', { operation_id: 'op-1' }),
|
||||
)
|
||||
const { isError } = await parsedToolResult(response)
|
||||
|
||||
expect(isError).toBe(true)
|
||||
const event = await eventPromise
|
||||
expect(event.errorKind).toBe('test_key_write_blocked')
|
||||
expect(event.success).toBe(false)
|
||||
// Exits before tool.execute() — no pending op is ever committed.
|
||||
expect(event.latencyMs).toBe(0)
|
||||
})
|
||||
|
||||
it('lets a read-only tool through for a test-mode key', async () => {
|
||||
const eventPromise = captureNextToolCalled()
|
||||
|
||||
await handleMcpRequest(mcpToolCall('gnubok_list_skills', {}))
|
||||
|
||||
const event = await eventPromise
|
||||
// Whatever happens inside execute(), the test-key guard must NOT fire on a
|
||||
// read tool.
|
||||
expect(event.errorKind).not.toBe('test_key_write_blocked')
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,7 @@ import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import {
|
||||
findMatchingVouchersForInvoice,
|
||||
validateVoucherForInvoiceLink,
|
||||
@@ -9057,6 +9058,135 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_generate_rot_rut_file',
|
||||
title: 'Generate Rot/Rut Payout File',
|
||||
description:
|
||||
'Begäran om utbetalning for rot/rut (Skatteverket husavdrag): XML file from paid deduction invoices, uploaded manually on skatteverket.se (no API exists). Call with list_only=true first to see eligible invoices and blockers. Generating records an active begäran per invoice.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
deduction_type: { type: 'string', enum: ['rot', 'rut'] },
|
||||
list_only: {
|
||||
type: 'boolean',
|
||||
description: 'Only list eligible + blocked invoices, generate nothing (default false)',
|
||||
},
|
||||
invoice_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Invoices to include. Omitted = all currently eligible.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description: 'NamnPaBegaran shown in Skatteverkets e-tjänst (max 16 chars). Omitted = generated.',
|
||||
},
|
||||
},
|
||||
required: ['deduction_type'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
deduction_type: { type: 'string' },
|
||||
eligible: { type: 'array', items: { type: 'object' } },
|
||||
blocked: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'Invoices excluded from begäran with per-invoice blocker code + Swedish message',
|
||||
},
|
||||
generated: { type: 'boolean' },
|
||||
request_id: { type: ['string', 'null'] },
|
||||
file_name: { type: ['string', 'null'] },
|
||||
xml: { type: ['string', 'null'], description: 'File content — save as UTF-8 .xml and upload on skatteverket.se' },
|
||||
requested_total: { type: 'number' },
|
||||
arenden: { type: 'array', items: { type: 'object' } },
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
upload_url: { type: 'string' },
|
||||
},
|
||||
required: ['deduction_type', 'generated'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false, // records a rot_rut_payout_requests row when generating
|
||||
destructiveHint: false,
|
||||
idempotentHint: false, // second call conflicts (one active begäran per invoice)
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const type = args.deduction_type as 'rot' | 'rut'
|
||||
if (type !== 'rot' && type !== 'rut') throw new Error('deduction_type must be rot or rut')
|
||||
const uploadUrl = 'https://www7.skatteverket.se/portal/rotrut/begar-utbetalning/fil'
|
||||
|
||||
const candidates = await listRotRutCandidates(supabase, companyId, type)
|
||||
if (!candidates.ok) throw new Error('Failed to list rot/rut candidates')
|
||||
|
||||
if (args.list_only === true) {
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: candidates.eligible,
|
||||
blocked: candidates.blocked,
|
||||
generated: false,
|
||||
request_id: null,
|
||||
file_name: null,
|
||||
xml: null,
|
||||
requested_total: candidates.eligible.reduce((sum, e) => sum + e.begart_belopp, 0),
|
||||
warnings: [],
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const requestedIds = Array.isArray(args.invoice_ids) && args.invoice_ids.length > 0
|
||||
? (args.invoice_ids as string[])
|
||||
: candidates.eligible.map((e) => e.invoice_id)
|
||||
if (requestedIds.length === 0) {
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: [],
|
||||
blocked: candidates.blocked,
|
||||
generated: false,
|
||||
request_id: null,
|
||||
file_name: null,
|
||||
xml: null,
|
||||
requested_total: 0,
|
||||
warnings: ['Inga fakturor är redo att begäras. Se blocked för orsaker per faktura.'],
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createRotRutPayoutRequest(supabase, companyId, userId, {
|
||||
type,
|
||||
invoiceIds: requestedIds,
|
||||
name: typeof args.name === 'string' ? args.name : undefined,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const blockerLines = (result.blockers ?? [])
|
||||
.map((b) => `${b.invoice_number ?? b.invoice_id}: ${b.message}`)
|
||||
.join(' | ')
|
||||
throw new Error(
|
||||
result.code === 'ROT_RUT_INVOICE_CONFLICT'
|
||||
? 'Minst en faktura ingår redan i en aktiv begäran om utbetalning.'
|
||||
: `Filen kunde inte skapas (${result.code}).${blockerLines ? ` ${blockerLines}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: candidates.eligible,
|
||||
blocked: candidates.blocked,
|
||||
generated: true,
|
||||
request_id: result.request.id as string,
|
||||
file_name: result.file.file_name,
|
||||
xml: result.file.xml,
|
||||
requested_total: result.file.requested_total,
|
||||
arenden: result.file.arenden,
|
||||
warnings: result.file.warnings,
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_audit_package',
|
||||
title: 'Generate Audit Package',
|
||||
@@ -11386,7 +11516,7 @@ function emitToolCallTelemetry(payload: {
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorCode: string | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
@@ -11650,7 +11780,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName } = authResult
|
||||
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult
|
||||
const supabase = createServiceClientNoCookies()
|
||||
// The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical
|
||||
// way for an agent to keep a stable identifier across tools/call invocations
|
||||
@@ -11870,6 +12000,47 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
// Test-mode API keys are simulation-only. Mirror the v1 REST guard
|
||||
// (lib/api/v1/with-api-v1.ts): force dry-run on any write tool that
|
||||
// supports it, and block writes that cannot be simulated. Without this a
|
||||
// gnubok_sk_test_ key — which is bound to the real active company — could
|
||||
// stage real pending_operations here and, with the approve scope, commit
|
||||
// them. Runs before execute() so nothing is ever staged for a test key.
|
||||
if (keyMode === 'test' && tool.annotations?.readOnlyHint === false) {
|
||||
const props = (tool.inputSchema as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties
|
||||
if (props && 'dry_run' in props) {
|
||||
;(toolArgs as Record<string, unknown>).dry_run = true
|
||||
} else {
|
||||
const blocked = toToolError(
|
||||
new Error(
|
||||
'Test-nyckel kan inte utföra riktiga skrivningar mot det här verktyget. Använd en live-nyckel för skarpa operationer.'
|
||||
),
|
||||
{ toolName }
|
||||
)
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
requiredScope: requiredScope ?? null,
|
||||
actor,
|
||||
latencyMs: 0,
|
||||
success: false,
|
||||
isError: true,
|
||||
errorCode: blocked.error.code,
|
||||
errorKind: 'test_key_write_blocked',
|
||||
errorMessage: blocked.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
content: [{ type: 'text', text: JSON.stringify(blocked, null, 2) }],
|
||||
isError: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if THIS call follows the previous call's `next` hint — must
|
||||
// run before execute() so we don't double-store on this call. Emits
|
||||
// mcp.next_hint_followed when the agent's behaviour matches the hint.
|
||||
|
||||
@@ -426,6 +426,27 @@ describe('CreateInvoiceItemSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts orgnr-shaped brf_org_number values', () => {
|
||||
for (const value of ['769600-0000', '7696000000', '167696000000']) {
|
||||
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes an empty brf_org_number to null', () => {
|
||||
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: '' }))
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.brf_org_number).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed brf_org_number values', () => {
|
||||
// incl. a 12-digit value without the mandatory sekelsiffra 16 prefix
|
||||
for (const value of ['---', '123', '76-96000000', 'ABC600-0000', '123456789012']) {
|
||||
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ brf_org_number: value }))
|
||||
expect(result.success).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a product row with an empty description', () => {
|
||||
const result = CreateInvoiceItemSchema.safeParse(validInvoiceItem({ description: ' ' }))
|
||||
expect(result.success).toBe(false)
|
||||
@@ -1307,6 +1328,82 @@ describe('UpdateSettingsSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invoice_email_texts', () => {
|
||||
it('accepts a valid nested partial', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: { sv: { body: 'Tack för din beställning!' } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.invoice_email_texts).toEqual({
|
||||
sv: { body: 'Tack för din beställning!' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts both languages with all four fields', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: {
|
||||
sv: {
|
||||
subject: 'Faktura {fakturanummer}',
|
||||
greeting: 'Hejsan,',
|
||||
body: 'Här kommer fakturan.',
|
||||
signoff: 'Allt gott,',
|
||||
},
|
||||
en: {
|
||||
subject: 'Invoice {fakturanummer}',
|
||||
greeting: 'Hello,',
|
||||
body: 'Please find the invoice attached.',
|
||||
signoff: 'Best,',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null to clear all overrides', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: null })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.data.invoice_email_texts).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects body over 2000 characters', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: { sv: { body: 'x'.repeat(2001) } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects subject over 200 characters', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: { sv: { subject: 'x'.repeat(201) } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a non-string field value', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: { sv: { subject: 123 } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('strips unknown keys inside a language object', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({
|
||||
invoice_email_texts: { sv: { body: 'Hej', subjct: 'typo' } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.invoice_email_texts).toEqual({ sv: { body: 'Hej' } })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a bare string as the column value', () => {
|
||||
const result = UpdateSettingsSchema.safeParse({ invoice_email_texts: 'Tack!' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -285,6 +285,18 @@ export const CreateInvoiceItemSchema = z
|
||||
work_type: z.string().max(64).nullable().optional(),
|
||||
housing_designation: z.string().max(128).nullable().optional(),
|
||||
apartment_number: z.string().max(32).nullable().optional(),
|
||||
// Bostadsrättsföreningens orgnr (XSD BrfOrgNrTYPE). If present it must be
|
||||
// a real orgnr shape — 10 digits (optional dash after position 6) or the
|
||||
// 12-digit sekelsiffra form — or Skatteverkets schemavalidering rejects
|
||||
// the whole file at upload time. Empty string = cleared field → null.
|
||||
brf_org_number: z
|
||||
.union([
|
||||
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
|
||||
z.literal(''),
|
||||
])
|
||||
.transform((v) => v || null)
|
||||
.nullable()
|
||||
.optional(),
|
||||
// Periodisering (förutbetald intäkt): defer the line's net revenue over
|
||||
// the service period. The revenue entry credits the 29xx interim account
|
||||
// instead of the revenue account; output VAT is never deferred.
|
||||
@@ -354,6 +366,18 @@ export const CreateInvoiceSchema = z.object({
|
||||
// present (enforced via rot-rut-rules.validateInvoice in the API).
|
||||
deduction_personnummer: z.string().max(20).optional(),
|
||||
deduction_housing_designation: z.string().max(128).optional(),
|
||||
// ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr replace the
|
||||
// fastighetsbeteckning (Begaran.xsd: LagenhetsNr + BrfOrgNr). Stamped onto
|
||||
// the rot lines server-side, same as deduction_housing_designation.
|
||||
deduction_apartment_number: z.string().max(25).optional(),
|
||||
// Same orgnr shape rule as items[].brf_org_number; empty string = not set.
|
||||
deduction_brf_org_number: z
|
||||
.union([
|
||||
z.string().regex(/^(\d{6}-?\d{4}|16\d{10})$/, 'Ogiltigt organisationsnummer (10 siffror, ev. med bindestreck)'),
|
||||
z.literal(''),
|
||||
])
|
||||
.transform((v) => v || undefined)
|
||||
.optional(),
|
||||
// When true, save as an unnumbered draft: skip F-series allocation and the
|
||||
// invoice.created event until the user finalizes via POST /invoices/{id}/finalize
|
||||
// ("Granska och skapa"). An unnumbered draft is not yet an issued faktura
|
||||
@@ -379,6 +403,36 @@ export const CreateCreditNoteSchema = z.object({
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Rot/rut begäran om utbetalning (Skatteverkets husavdragstjänst)
|
||||
// ============================================================
|
||||
|
||||
export const RotRutPayoutFileSchema = z.object({
|
||||
deduction_type: z.enum(['rot', 'rut']),
|
||||
invoice_ids: z.array(uuid).min(1).max(500),
|
||||
// NamnPaBegaran — the XSD caps it at 16 chars; omitted → generated.
|
||||
name: z.string().min(1).max(16).optional(),
|
||||
})
|
||||
|
||||
export const RotRutRequestPatchSchema = z.object({
|
||||
status: z.enum(['submitted', 'paid', 'partially_paid', 'rejected', 'cancelled']),
|
||||
// Godkänt belopp from Skatteverkets beslut. Only meaningful together with
|
||||
// paid/partially_paid/rejected.
|
||||
decided_total: nonNegativeAmount.optional(),
|
||||
})
|
||||
|
||||
export const RotRutSettleSchema = z.object({
|
||||
payment_date: isoDate,
|
||||
// Defaults server-side to decided_total ?? requested_total.
|
||||
amount: z.number().positive().optional(),
|
||||
// BAS 19xx account the payout landed on (1920 Bank, 1930 Företagskonto, …).
|
||||
// Omitted → 1930. The engine validates existence against chart_of_accounts.
|
||||
bank_account: z
|
||||
.string()
|
||||
.regex(/^19\d{2}$/, 'Bankkontot måste vara ett BAS 19xx-konto')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Articles (artikelregister)
|
||||
// ============================================================
|
||||
@@ -1165,6 +1219,24 @@ export const MatchSupplierInvoiceSchema = z.object({
|
||||
// Settings schemas
|
||||
// ============================================================
|
||||
|
||||
// Editable invoice email texts (standard invoices only). Nested JSONB —
|
||||
// unknown keys inside are stripped (Zod default, consistent with this file).
|
||||
// Empty strings pass validation; the template resolver treats whitespace-only
|
||||
// as unset, and the UI prunes empties before saving so the stored object
|
||||
// stays minimal. Subject is a mail header: CR/LF are stripped at render time
|
||||
// regardless.
|
||||
const InvoiceEmailTextsLangSchema = z.object({
|
||||
subject: z.string().max(200, 'Ämnesraden får vara max 200 tecken').optional(),
|
||||
greeting: z.string().max(200, 'Hälsningen får vara max 200 tecken').optional(),
|
||||
body: z.string().max(2000, 'Brödtexten får vara max 2000 tecken').optional(),
|
||||
signoff: z.string().max(200, 'Avslutningen får vara max 200 tecken').optional(),
|
||||
})
|
||||
|
||||
export const InvoiceEmailTextsSchema = z.object({
|
||||
sv: InvoiceEmailTextsLangSchema.optional(),
|
||||
en: InvoiceEmailTextsLangSchema.optional(),
|
||||
})
|
||||
|
||||
export const UpdateSettingsSchema = z.object({
|
||||
entity_type: EntityTypeSchema.optional(),
|
||||
company_name: z.string().optional(),
|
||||
@@ -1242,6 +1314,10 @@ export const UpdateSettingsSchema = z.object({
|
||||
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
|
||||
invoice_late_fee_text: z.string().nullable().optional(),
|
||||
invoice_credit_terms_text: z.string().nullable().optional(),
|
||||
// Editable invoice email texts — { sv?: {...}, en?: {...} }; null clears
|
||||
// all overrides. Without this entry the generic PUT would silently strip
|
||||
// the field (the schema is the de-facto column whitelist).
|
||||
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
|
||||
// Invoice branding — colors enforced as #RRGGBB at the DB level too
|
||||
// (see migration 20260526120200_invoice_branding.sql). The dedicated
|
||||
// /api/settings/invoicing/branding route is the primary path; these
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
||||
|
||||
describe('apiPathSkipsMfaGate', () => {
|
||||
it('skips the gate only on Bearer-auth surfaces when an Authorization header is present', () => {
|
||||
expect(apiPathSkipsMfaGate('/api/v1/companies/abc/invoices', true)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/extensions/ext/mcp-server/mcp', true)).toBe(true)
|
||||
})
|
||||
|
||||
it('never lets an Authorization header disable the gate on cookie-authenticated routes', () => {
|
||||
// A stolen-password AAL1 cookie session must not bypass MFA by attaching
|
||||
// a garbage Authorization header the route ignores (Superagent P2, #878).
|
||||
expect(apiPathSkipsMfaGate('/api/bookkeeping/journal-entries/123', true)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/reports/full-archive', true)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/salary/employees/1', true)).toBe(false)
|
||||
// Non-MCP extension routes authenticate via cookies in the dispatcher.
|
||||
expect(apiPathSkipsMfaGate('/api/extensions/ext/invoice-inbox/custom-domain', true)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not skip Bearer-auth surfaces without an Authorization header', () => {
|
||||
expect(apiPathSkipsMfaGate('/api/v1/companies/abc/invoices', false)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/extensions/ext/mcp-server/mcp', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the gate for the AAL1 escape-hatch and OAuth routes', () => {
|
||||
expect(apiPathSkipsMfaGate('/api/account/password', false)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/account/set-password', false)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/company', false)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/company/members', false)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/mcp-oauth/authorize', false)).toBe(true)
|
||||
expect(apiPathSkipsMfaGate('/api/mcp-oauth/token', false)).toBe(true)
|
||||
})
|
||||
|
||||
it('gates cookie-authenticated calls to sensitive dashboard routes', () => {
|
||||
expect(apiPathSkipsMfaGate('/api/bookkeeping/journal-entries/123', false)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/salary/employees/1', false)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/reports/full-archive', false)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/documents/1', false)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a lookalike prefix bypass the account/company allowlist', () => {
|
||||
// "/api/accounts" (plural, a different resource) must NOT match the
|
||||
// "/api/account/" escape hatch.
|
||||
expect(apiPathSkipsMfaGate('/api/accounts', false)).toBe(false)
|
||||
expect(apiPathSkipsMfaGate('/api/account', false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -243,6 +243,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_export_sie: 'reports:read',
|
||||
gnubok_audit_package: 'reports:read',
|
||||
gnubok_import_sie: 'bookkeeping:write',
|
||||
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
||||
gnubok_generate_rot_rut_file: 'invoices:write',
|
||||
// Supplier CRUD
|
||||
gnubok_create_supplier: 'suppliers:write',
|
||||
// Supplier invoice lifecycle
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Decide whether an `/api` request should SKIP the middleware MFA (AAL2) gate.
|
||||
*
|
||||
* Most `/api` routes historically hand-roll `supabase.auth.getUser()` instead
|
||||
* of `requireAuth()`, which means they never enforce MFA. The middleware gate
|
||||
* (lib/supabase/middleware.ts) closes that gap for cookie sessions, but a few
|
||||
* request classes must NOT be gated:
|
||||
*
|
||||
* - Bearer-authenticated SURFACES (`/api/v1/*` API keys, the MCP endpoint's
|
||||
* OAuth tokens/API keys): the route validates the Authorization credential
|
||||
* itself and never trusts the cookie session, so a logged-in AAL1 browser
|
||||
* testing its own API key must not be blocked. This is scoped by PATH, not
|
||||
* header presence — the header is attacker-controlled, and an Authorization
|
||||
* header riding on a cookie-authenticated route must never disable the
|
||||
* gate (the route would ignore the header and authenticate via cookies,
|
||||
* i.e. a stolen-password session could bypass MFA with `Authorization: x`).
|
||||
* Pure Bearer callers elsewhere (cron secret, signed webhooks) carry no
|
||||
* cookie session, so the gate — which only fires for cookie users — never
|
||||
* touches them and they need no exemption.
|
||||
* - The AAL1 escape hatch: a user with MFA required but not yet verified (or a
|
||||
* BankID-only user setting a first password) must still reach
|
||||
* `/api/account/*` and `/api/company*` to COMPLETE onboarding / enroll MFA.
|
||||
* - The MCP OAuth endpoints (`/api/mcp-oauth/*`) carry their own PKCE +
|
||||
* single-use-code security and drive the connector authorize flow.
|
||||
*
|
||||
* Kept as a pure function so the allowlist is unit-testable in isolation.
|
||||
*/
|
||||
|
||||
// Routes whose auth contract IS the Authorization header. Everything else
|
||||
// under /api/extensions/ext/ authenticates via requireAuth (cookies) in the
|
||||
// dispatcher and must stay behind the gate.
|
||||
const BEARER_AUTH_PREFIXES = ['/api/v1/', '/api/extensions/ext/mcp-server/mcp']
|
||||
|
||||
export function apiPathSkipsMfaGate(
|
||||
pathname: string,
|
||||
hasAuthorizationHeader: boolean,
|
||||
): boolean {
|
||||
if (
|
||||
hasAuthorizationHeader &&
|
||||
BEARER_AUTH_PREFIXES.some((prefix) => pathname.startsWith(prefix))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
pathname.startsWith('/api/account/') ||
|
||||
pathname.startsWith('/api/company') ||
|
||||
pathname.startsWith('/api/mcp-oauth/')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CreateJournalEntryInput, JournalEntry } from '@/types'
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
|
||||
/**
|
||||
* Settlement voucher for a rot/rut payout from Skatteverket.
|
||||
*
|
||||
* When the agency pays out a begäran (one lump sum per request), the 1513
|
||||
* receivable created at invoicing (fakturamodellen) clears against the bank:
|
||||
*
|
||||
* Debit 19xx bank account (default 1930) [amount]
|
||||
* Credit 1513 Skattereduktion rot/rut [amount]
|
||||
*
|
||||
* One voucher per payout request — that mirrors the actual bank transaction.
|
||||
* At partial approval (delvis beviljad) the paid amount clears here and the
|
||||
* remainder stays on 1513 until the user corrects it (kundfordran/kundförlust
|
||||
* depending on the outcome with the buyer) — deliberately manual, never
|
||||
* guessed.
|
||||
*/
|
||||
export async function createRotRutPayoutEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
params: {
|
||||
requestId: string
|
||||
requestName: string
|
||||
deductionType: 'rot' | 'rut'
|
||||
paymentDate: string
|
||||
amount: number
|
||||
/** BAS 19xx account the payout landed on. Defaults to 1930. */
|
||||
bankAccount?: string
|
||||
},
|
||||
): Promise<JournalEntry> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
throw new Error(`No open fiscal period found for payment date ${params.paymentDate}`)
|
||||
}
|
||||
|
||||
const amount = Math.round(params.amount * 100) / 100
|
||||
const bankAccount = params.bankAccount ?? '1930'
|
||||
const label = params.deductionType === 'rot' ? 'ROT' : 'RUT'
|
||||
const description = `Utbetalning ${label}-avdrag från Skatteverket (${params.requestName})`
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: params.paymentDate,
|
||||
description,
|
||||
source_type: 'rot_rut_payout',
|
||||
source_id: params.requestId,
|
||||
lines: [
|
||||
{
|
||||
account_number: bankAccount,
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: description,
|
||||
},
|
||||
{
|
||||
account_number: '1513',
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: description,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return createJournalEntry(supabase, companyId, userId, input)
|
||||
}
|
||||
@@ -116,6 +116,61 @@ describe('validateDocumentMagicBytes — application/xhtml+xml', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateDocumentMagicBytes — PDF header offset tolerance', () => {
|
||||
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
|
||||
const withPreamble = (preamble: string): ArrayBuffer => {
|
||||
const pdf = new Uint8Array(pdfBuffer())
|
||||
const lead = new TextEncoder().encode(preamble)
|
||||
const combined = new Uint8Array(lead.length + pdf.length)
|
||||
combined.set(lead, 0)
|
||||
combined.set(pdf, lead.length)
|
||||
return toArrayBuffer(combined)
|
||||
}
|
||||
|
||||
it('accepts a PDF with a leading newline before %PDF- (ISO 32000 preamble)', () => {
|
||||
expect(validateDocumentMagicBytes(withPreamble('\n'), 'application/pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a PDF with leading whitespace/junk before %PDF-', () => {
|
||||
expect(validateDocumentMagicBytes(withPreamble(' '), 'application/pdf')).toBeNull()
|
||||
expect(validateDocumentMagicBytes(withPreamble('\r\n\r\n<junk>'), 'application/pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a PDF with a UTF-8 BOM before %PDF-', () => {
|
||||
const pdf = new Uint8Array(pdfBuffer())
|
||||
const combined = new Uint8Array(3 + pdf.length)
|
||||
combined.set([0xEF, 0xBB, 0xBF], 0)
|
||||
combined.set(pdf, 3)
|
||||
expect(validateDocumentMagicBytes(toArrayBuffer(combined), 'application/pdf')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects when %PDF- appears only beyond the first 1024 bytes', () => {
|
||||
expect(validateDocumentMagicBytes(withPreamble('x'.repeat(1025)), 'application/pdf')).toMatch(
|
||||
/kunde inte verifieras/,
|
||||
)
|
||||
})
|
||||
|
||||
it('still rejects HTML and plain text declared as PDF', () => {
|
||||
const toBuffer = (text: string): ArrayBuffer =>
|
||||
toArrayBuffer(new TextEncoder().encode(text))
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('<html><body>Your invoice</body></html>'), 'application/pdf'),
|
||||
).toMatch(/kunde inte verifieras/)
|
||||
expect(
|
||||
validateDocumentMagicBytes(toBuffer('JVBERi0xLjQKJcOkw7zDtsO'), 'application/pdf'),
|
||||
).toMatch(/kunde inte verifieras/)
|
||||
})
|
||||
|
||||
it('images stay strict at offset 0 — a leading byte still rejects', () => {
|
||||
const png = new Uint8Array([0x0A, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||
expect(validateDocumentMagicBytes(toArrayBuffer(png), 'image/png')).toMatch(
|
||||
/kunde inte verifieras/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
|
||||
const doc = makeDocumentAttachment({
|
||||
|
||||
@@ -65,16 +65,22 @@ export function validateDocumentFile(file: { size: number; type?: string }): str
|
||||
*/
|
||||
function detectFileMagic(bytes: Uint8Array): string | null {
|
||||
if (bytes.length < 4) return null
|
||||
// PDF: %PDF- (allow a leading UTF-8 BOM as some tools prepend one)
|
||||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||||
if (
|
||||
bytes.length >= offset + 5 &&
|
||||
bytes[offset] === 0x25 &&
|
||||
bytes[offset + 1] === 0x50 &&
|
||||
bytes[offset + 2] === 0x44 &&
|
||||
bytes[offset + 3] === 0x46 &&
|
||||
bytes[offset + 4] === 0x2D
|
||||
) return 'application/pdf'
|
||||
// PDF: %PDF- anywhere in the first 1024 bytes. ISO 32000 readers accept a
|
||||
// preamble before the header (Acrobat scans the first 1 KB), and real-world
|
||||
// invoice PDFs arrive with leading newlines/junk — requiring offset 0
|
||||
// rejected files every normal reader opens. Image types stay strict at
|
||||
// offset 0: genuine image files always start with their signature, and the
|
||||
// looseness is not needed there to keep the anti-placeholder defense tight.
|
||||
const pdfScanEnd = Math.min(bytes.length - 5, 1024)
|
||||
for (let i = 0; i <= pdfScanEnd; i++) {
|
||||
if (
|
||||
bytes[i] === 0x25 &&
|
||||
bytes[i + 1] === 0x50 &&
|
||||
bytes[i + 2] === 0x44 &&
|
||||
bytes[i + 3] === 0x46 &&
|
||||
bytes[i + 4] === 0x2D
|
||||
) return 'application/pdf'
|
||||
}
|
||||
// PNG: 89 50 4E 47
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||||
// JPEG: FF D8 FF
|
||||
|
||||
@@ -141,4 +141,220 @@ describe('invoice email templates', () => {
|
||||
expect(svHtml).toMatch(/1[\s\u00a0]000,00 EUR/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom email texts (invoice_email_texts)', () => {
|
||||
const svCustomer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
|
||||
const enCustomer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
|
||||
|
||||
const fullOverrides = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: {
|
||||
sv: {
|
||||
subject: 'Er faktura {fakturanummer} \u2013 {f\u00f6retag}',
|
||||
greeting: 'Hejsan {f\u00f6rnamn}!',
|
||||
body: 'H\u00e4r kommer m\u00e5nadens faktura.',
|
||||
signoff: 'Allt gott,',
|
||||
},
|
||||
en: {
|
||||
subject: 'Your invoice {fakturanummer}',
|
||||
greeting: 'Hello {f\u00f6rnamn}!',
|
||||
body: "Please find this month's invoice attached.",
|
||||
signoff: 'Best,',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
it('renders sv overrides in the HTML variant, keeping structural parts', () => {
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: fullOverrides })
|
||||
expect(html).toContain('Hejsan Erik!')
|
||||
expect(html).toContain('H\u00e4r kommer m\u00e5nadens faktura.')
|
||||
expect(html).toContain('Allt gott,')
|
||||
expect(html).not.toContain('Tack f\u00f6r ditt f\u00f6rtroende')
|
||||
expect(html).not.toContain('Med v\u00e4nliga h\u00e4lsningar,')
|
||||
// Structural parts and the footer question line stay generated
|
||||
expect(html).toContain('Betalningsinformation')
|
||||
expect(html).toContain('Har du fr\u00e5gor om fakturan?')
|
||||
})
|
||||
|
||||
it('renders sv overrides in the text variant', () => {
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: fullOverrides })
|
||||
expect(text).toContain('Hejsan Erik!')
|
||||
expect(text).toContain('H\u00e4r kommer m\u00e5nadens faktura.')
|
||||
expect(text).toContain('Allt gott,')
|
||||
expect(text).not.toContain('Med v\u00e4nliga h\u00e4lsningar,')
|
||||
})
|
||||
|
||||
it('substitutes placeholders in the subject', () => {
|
||||
const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: fullOverrides })
|
||||
expect(subject).toBe('Er faktura 1042 \u2013 Acme AB')
|
||||
})
|
||||
|
||||
it('uses the en overrides for English customers', () => {
|
||||
const subject = generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: fullOverrides })
|
||||
expect(subject).toBe('Your invoice 1042')
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: fullOverrides })
|
||||
expect(html).toContain('Hello Jane!')
|
||||
})
|
||||
|
||||
it('falls back per language: sv-only overrides leave English customers on stock texts', () => {
|
||||
const svOnly = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: enCustomer, company: svOnly })
|
||||
expect(html).toContain('Hi Jane,')
|
||||
expect(html).toContain('Thank you for your business')
|
||||
expect(generateInvoiceEmailSubject({ invoice, customer: enCustomer, company: svOnly }))
|
||||
.toBe('Invoice 1042 from Acme AB')
|
||||
})
|
||||
|
||||
it('falls back per field: only overridden fields change', () => {
|
||||
const bodyOnly = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: 'H\u00e4r kommer fakturan.' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: bodyOnly })
|
||||
expect(html).toContain('H\u00e4r kommer fakturan.')
|
||||
expect(html).toContain('Hej Erik,')
|
||||
expect(html).toContain('Med v\u00e4nliga h\u00e4lsningar,')
|
||||
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: bodyOnly }))
|
||||
.toBe('Faktura 1042 fr\u00e5n Acme AB')
|
||||
})
|
||||
|
||||
it('treats whitespace-only overrides as unset', () => {
|
||||
const blank = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: ' ', subject: '\n' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: blank })
|
||||
expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende')
|
||||
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: blank }))
|
||||
.toBe('Faktura 1042 fr\u00e5n Acme AB')
|
||||
})
|
||||
|
||||
it('substitutes all six placeholders with per-language formatting', () => {
|
||||
const allPlaceholders = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: {
|
||||
sv: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' },
|
||||
en: { body: '{fakturanummer} {kundnamn} {f\u00f6rnamn} {f\u00f6retag} {f\u00f6rfallodatum} {belopp}' },
|
||||
},
|
||||
})
|
||||
const svText = generateInvoiceEmailText({ invoice, customer: svCustomer, company: allPlaceholders })
|
||||
expect(svText).toContain('1042 Erik Andersson Erik Acme AB 2026-06-21')
|
||||
expect(svText).toMatch(/12[\s\u00a0]500,00 SEK/)
|
||||
|
||||
const enText = generateInvoiceEmailText({ invoice, customer: enCustomer, company: allPlaceholders })
|
||||
expect(enText).toContain('1042 Jane Doe Jane Acme AB 2026-06-21 12,500.00 SEK')
|
||||
})
|
||||
|
||||
it('leaves unknown placeholders literal', () => {
|
||||
const typo = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { subject: 'Faktura {fakturanumer}', body: 'Se {bilaga}' } },
|
||||
})
|
||||
expect(generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: typo }))
|
||||
.toBe('Faktura {fakturanumer}')
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: typo })
|
||||
expect(text).toContain('Se {bilaga}')
|
||||
})
|
||||
|
||||
it('is forgiving about placeholder case and spacing', () => {
|
||||
const spaced = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { greeting: 'Hej { F\u00f6rnamn }!' } },
|
||||
})
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: spaced })
|
||||
expect(text).toContain('Hej Erik!')
|
||||
})
|
||||
|
||||
it('escapes HTML in custom texts but keeps the text variant verbatim', () => {
|
||||
const xss = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: '<script>alert(1)</script> & "quoted"' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: xss })
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).toContain('<script>alert(1)</script> & "quoted"')
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: xss })
|
||||
expect(text).toContain('<script>alert(1)</script> & "quoted"')
|
||||
})
|
||||
|
||||
it('escapes substituted placeholder values in the HTML variant', () => {
|
||||
const trickyCustomer = makeCustomer({ name: 'Bj\u00f6rk & S\u00f6ner <AB>', language: 'sv' })
|
||||
const withName = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: 'Till {kundnamn}.' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: trickyCustomer, company: withName })
|
||||
expect(html).toContain('Till Bj\u00f6rk & S\u00f6ner <AB>.')
|
||||
const text = generateInvoiceEmailText({ invoice, customer: trickyCustomer, company: withName })
|
||||
expect(text).toContain('Till Bj\u00f6rk & S\u00f6ner <AB>.')
|
||||
})
|
||||
|
||||
it('converts newlines in the body to <br> in HTML and keeps them in text', () => {
|
||||
const multiline = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { body: 'Rad 1\nRad 2' } },
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice, customer: svCustomer, company: multiline })
|
||||
expect(html).toContain('Rad 1<br>Rad 2')
|
||||
const text = generateInvoiceEmailText({ invoice, customer: svCustomer, company: multiline })
|
||||
expect(text).toContain('Rad 1\nRad 2')
|
||||
})
|
||||
|
||||
it('flattens newlines in a custom subject (header injection)', () => {
|
||||
const inject = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: {
|
||||
sv: { subject: 'Faktura {fakturanummer}\r\nBcc: attacker@example.com' },
|
||||
},
|
||||
})
|
||||
const subject = generateInvoiceEmailSubject({ invoice, customer: svCustomer, company: inject })
|
||||
expect(subject).toBe('Faktura 1042 Bcc: attacker@example.com')
|
||||
expect(subject).not.toMatch(/[\r\n]/)
|
||||
})
|
||||
|
||||
it('does not re-substitute placeholder-like values (single pass)', () => {
|
||||
const weirdCustomer = makeCustomer({ name: '{belopp} AB', language: 'sv' })
|
||||
const greetByName = makeCompanySettings({
|
||||
company_name: 'Acme AB',
|
||||
invoice_email_texts: { sv: { greeting: 'Hej {kundnamn}!' } },
|
||||
})
|
||||
const text = generateInvoiceEmailText({ invoice, customer: weirdCustomer, company: greetByName })
|
||||
expect(text).toContain('Hej {belopp} AB!')
|
||||
})
|
||||
|
||||
it('ignores overrides on credit notes', () => {
|
||||
const creditInvoice = makeInvoice({
|
||||
invoice_number: '1043',
|
||||
due_date: '2026-05-22',
|
||||
currency: 'SEK',
|
||||
total: -5000,
|
||||
credited_invoice_id: 'inv-orig',
|
||||
})
|
||||
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides })
|
||||
expect(html).toContain('Bifogat hittar du en kreditfaktura')
|
||||
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
|
||||
expect(generateInvoiceEmailSubject({ invoice: creditInvoice, customer: svCustomer, company: fullOverrides }))
|
||||
.toBe('Kreditfaktura 1043 fr\u00e5n Acme AB')
|
||||
})
|
||||
|
||||
it('ignores overrides on proforma invoices', () => {
|
||||
const proforma = makeInvoice({ invoice_number: '1044', document_type: 'proforma' })
|
||||
const html = generateInvoiceEmailHtml({ invoice: proforma, customer: svCustomer, company: fullOverrides })
|
||||
expect(html).toContain('Tack f\u00f6r ditt f\u00f6rtroende')
|
||||
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
|
||||
expect(generateInvoiceEmailSubject({ invoice: proforma, customer: svCustomer, company: fullOverrides }))
|
||||
.toBe('Proformafaktura 1044 fr\u00e5n Acme AB')
|
||||
})
|
||||
|
||||
it('ignores overrides on delivery notes', () => {
|
||||
const deliveryNote = makeInvoice({ invoice_number: '1045', document_type: 'delivery_note' })
|
||||
const html = generateInvoiceEmailHtml({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides })
|
||||
expect(html).not.toContain('H\u00e4r kommer m\u00e5nadens faktura.')
|
||||
expect(generateInvoiceEmailSubject({ invoice: deliveryNote, customer: svCustomer, company: fullOverrides }))
|
||||
.toBe('F\u00f6ljesedel 1045 fr\u00e5n Acme AB')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
escapeHtml,
|
||||
userTextToHtml,
|
||||
sanitizeSubjectLine,
|
||||
applyPlaceholders,
|
||||
} from '../user-text'
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes all five special characters', () => {
|
||||
expect(escapeHtml(`&<>"'`)).toBe('&<>"'')
|
||||
})
|
||||
|
||||
it('leaves normal text untouched', () => {
|
||||
expect(escapeHtml('Hej Erik, här är fakturan.')).toBe('Hej Erik, här är fakturan.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('userTextToHtml', () => {
|
||||
it('escapes before converting newlines so user-typed <br> stays escaped', () => {
|
||||
expect(userTextToHtml('a<br>b\nc')).toBe('a<br>b<br>c')
|
||||
})
|
||||
|
||||
it('converts \\r\\n, \\r and \\n to <br>', () => {
|
||||
expect(userTextToHtml('a\r\nb\rc\nd')).toBe('a<br>b<br>c<br>d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeSubjectLine', () => {
|
||||
it('flattens newlines to spaces and trims', () => {
|
||||
expect(sanitizeSubjectLine(' Faktura\r\n1042\n ')).toBe('Faktura 1042')
|
||||
})
|
||||
|
||||
it('leaves a normal subject untouched', () => {
|
||||
expect(sanitizeSubjectLine('Faktura 1042 från Acme AB')).toBe('Faktura 1042 från Acme AB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPlaceholders', () => {
|
||||
const values = { fakturanummer: '1042', förnamn: 'Erik' }
|
||||
|
||||
it('substitutes known keys', () => {
|
||||
expect(applyPlaceholders('Faktura {fakturanummer} till {förnamn}', values)).toBe(
|
||||
'Faktura 1042 till Erik',
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves unknown keys literal', () => {
|
||||
expect(applyPlaceholders('{fakturanumer}', values)).toBe('{fakturanumer}')
|
||||
})
|
||||
|
||||
it('is forgiving about case and inner whitespace', () => {
|
||||
expect(applyPlaceholders('{ Förnamn }', values)).toBe('Erik')
|
||||
})
|
||||
|
||||
it('leaves empty braces literal', () => {
|
||||
expect(applyPlaceholders('a {} b', values)).toBe('a {} b')
|
||||
})
|
||||
|
||||
it('does not re-substitute values (single pass)', () => {
|
||||
expect(applyPlaceholders('{namn}', { namn: '{fakturanummer}' })).toBe('{fakturanummer}')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
|
||||
import { applyPlaceholders, sanitizeSubjectLine, userTextToHtml } from './user-text'
|
||||
|
||||
type EmailLang = 'sv' | 'en'
|
||||
|
||||
@@ -68,10 +69,50 @@ const LABELS = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// Placeholder keys available in company-editable email texts
|
||||
// (company_settings.invoice_email_texts). Rendered as a legend in the
|
||||
// settings UI; kept here rather than in messages/*.json because ICU message
|
||||
// syntax treats literal braces as interpolation.
|
||||
export const INVOICE_EMAIL_PLACEHOLDER_KEYS = [
|
||||
'fakturanummer',
|
||||
'kundnamn',
|
||||
'förnamn',
|
||||
'företag',
|
||||
'förfallodatum',
|
||||
'belopp',
|
||||
] as const
|
||||
|
||||
// Display strings for the settings UI's input placeholder attributes.
|
||||
// subject and greeting are functions in LABELS, so their pattern form is
|
||||
// hand-written here; body/signoff reference LABELS directly so they cannot
|
||||
// drift from the actual defaults.
|
||||
export const INVOICE_EMAIL_DEFAULT_TEXTS = {
|
||||
sv: {
|
||||
subject: 'Faktura {fakturanummer} från {företag}',
|
||||
greeting: 'Hej {förnamn},',
|
||||
body: LABELS.sv.bodyInvoice,
|
||||
signoff: LABELS.sv.sincerely,
|
||||
},
|
||||
en: {
|
||||
subject: 'Invoice {fakturanummer} from {företag}',
|
||||
greeting: 'Hi {förnamn},',
|
||||
body: LABELS.en.bodyInvoice,
|
||||
signoff: LABELS.en.sincerely,
|
||||
},
|
||||
} as const
|
||||
|
||||
function resolveLang(customer: Customer): EmailLang {
|
||||
return customer.language === 'en' ? 'en' : 'sv'
|
||||
}
|
||||
|
||||
// Custom texts apply ONLY to standard invoices. Credit notes, proforma and
|
||||
// delivery notes always use the stock texts — a custom "Tack för ditt
|
||||
// förtroende..." body or "Faktura..." subject would be wrong on those.
|
||||
function isStandardInvoice(invoice: Invoice): boolean {
|
||||
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
|
||||
return docType === 'invoice' && !invoice.credited_invoice_id
|
||||
}
|
||||
|
||||
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
|
||||
const L = LABELS[lang]
|
||||
if (invoice.credited_invoice_id) return L.docCreditNote
|
||||
@@ -100,6 +141,47 @@ export interface InvoiceEmailData {
|
||||
company: CompanySettings
|
||||
}
|
||||
|
||||
function buildPlaceholderValues(data: InvoiceEmailData, lang: EmailLang): Record<string, string> {
|
||||
const { invoice, customer, company } = data
|
||||
const fullName = (customer.name || '').trim()
|
||||
return {
|
||||
fakturanummer: invoice.invoice_number ?? '',
|
||||
kundnamn: fullName,
|
||||
förnamn: fullName ? fullName.split(' ')[0] : '',
|
||||
företag: getCompanyPrimaryName(company),
|
||||
förfallodatum: formatDate(invoice.due_date),
|
||||
belopp: formatCurrencyForCustomer(invoice.total, invoice.currency, lang),
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedCustomTexts {
|
||||
subject?: string
|
||||
greeting?: string
|
||||
body?: string
|
||||
signoff?: string
|
||||
}
|
||||
|
||||
// Resolves the company's custom email texts for one language. Per-field
|
||||
// fallback: missing / non-string / whitespace-only values return undefined
|
||||
// and the caller uses the stock text. Returns RAW substituted strings —
|
||||
// escaping is the caller's job per output variant (HTML vs text vs subject).
|
||||
// Defensive typeof checks: rows can be written outside Zod (scripts, SQL).
|
||||
function resolveCustomTexts(data: InvoiceEmailData, lang: EmailLang): ResolvedCustomTexts {
|
||||
if (!isStandardInvoice(data.invoice)) return {}
|
||||
const texts = data.company.invoice_email_texts
|
||||
const langTexts = texts && typeof texts === 'object' ? texts[lang] : undefined
|
||||
if (!langTexts || typeof langTexts !== 'object') return {}
|
||||
const values = buildPlaceholderValues(data, lang)
|
||||
const pick = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v.trim() !== '' ? applyPlaceholders(v.trim(), values) : undefined
|
||||
return {
|
||||
subject: pick(langTexts.subject),
|
||||
greeting: pick(langTexts.greeting),
|
||||
body: pick(langTexts.body),
|
||||
signoff: pick(langTexts.signoff),
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal hex validator — guards against branding values that bypass the
|
||||
// settings UI and could inject CSS via crafted strings. Anything malformed
|
||||
// falls back to the legacy default.
|
||||
@@ -123,6 +205,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
||||
const custom = resolveCustomTexts(data, lang)
|
||||
|
||||
// Primary color drives the heading accent and the highlighted total. The
|
||||
// accent is sanitized to a strict hex pattern — anything else falls back
|
||||
@@ -154,10 +237,10 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
<!-- Greeting -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p style="margin: 0 0 15px 0;">
|
||||
${L.greeting(firstName)}
|
||||
${custom.greeting !== undefined ? userTextToHtml(custom.greeting) : L.greeting(firstName)}
|
||||
</p>
|
||||
<p style="margin: 0;">
|
||||
${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}
|
||||
${custom.body !== undefined ? userTextToHtml(custom.body) : (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +318,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
|
||||
${L.questions}
|
||||
</p>
|
||||
<p style="margin: 0; color: #666; font-size: 14px;">
|
||||
${L.sincerely}<br>
|
||||
${custom.signoff !== undefined ? userTextToHtml(custom.signoff) : L.sincerely}<br>
|
||||
<strong style="color: ${primaryColor};">${getCompanyPrimaryName(company)}</strong>
|
||||
</p>
|
||||
${company.org_number ? `
|
||||
@@ -267,13 +350,14 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
const isProforma = docType === 'proforma'
|
||||
const hidePayment = isCreditNote || isDeliveryNote || isProforma
|
||||
const firstName = customer.name ? customer.name.split(' ')[0] : ''
|
||||
const custom = resolveCustomTexts(data, lang)
|
||||
|
||||
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
|
||||
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
|
||||
|
||||
text += `${L.greeting(firstName)}\n\n`
|
||||
text += `${custom.greeting ?? L.greeting(firstName)}\n\n`
|
||||
|
||||
text += `${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}\n\n`
|
||||
text += `${custom.body ?? (isCreditNote ? L.bodyCreditNote : L.bodyInvoice)}\n\n`
|
||||
|
||||
text += `${L.documentSummary(documentType)}\n`
|
||||
text += `---\n`
|
||||
@@ -295,7 +379,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
|
||||
}
|
||||
|
||||
text += `${L.questions}\n\n`
|
||||
text += `${L.sincerely}\n`
|
||||
text += `${custom.signoff ?? L.sincerely}\n`
|
||||
text += `${getCompanyDisplayName(company)}\n`
|
||||
|
||||
if (company.org_number) {
|
||||
@@ -315,7 +399,12 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
|
||||
const { invoice, customer, company } = data
|
||||
const lang = resolveLang(customer)
|
||||
const L = LABELS[lang]
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
|
||||
// Sanitization runs after substitution, so a pathological placeholder
|
||||
// value containing a newline is also flattened to a single header line.
|
||||
const custom = resolveCustomTexts(data, lang)
|
||||
if (custom.subject !== undefined) return sanitizeSubjectLine(custom.subject)
|
||||
|
||||
const documentType = getDocumentLabel(invoice, lang)
|
||||
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Utilities for safely rendering user-authored text in outgoing emails.
|
||||
*
|
||||
* Company-editable email texts (see company_settings.invoice_email_texts)
|
||||
* are untrusted input: they must be escaped before interpolation into HTML
|
||||
* templates and kept single-line when used as mail headers.
|
||||
*/
|
||||
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Escape FIRST, then convert newlines — order matters: a '<br>' typed by the
|
||||
// user must arrive escaped; only OUR <br> survives.
|
||||
export function userTextToHtml(input: string): string {
|
||||
return escapeHtml(input).replace(/\r\n|\r|\n/g, '<br>')
|
||||
}
|
||||
|
||||
// Mail-header hygiene: a subject must be a single line (header injection).
|
||||
export function sanitizeSubjectLine(input: string): string {
|
||||
return input.replace(/[\r\n]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Fixed-set {x} substitution. Single pass: substituted VALUES are never
|
||||
// re-scanned, so a customer named '{belopp}' stays literal. Unknown keys are
|
||||
// left as-is so the user sees and fixes typos. Keys are matched after
|
||||
// trim + toLowerCase (forgiving of '{ Förnamn }').
|
||||
export function applyPlaceholders(
|
||||
template: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
return template.replace(/\{([^{}]*)\}/g, (match, rawKey: string) => {
|
||||
const value = values[rawKey.trim().toLowerCase()]
|
||||
return value !== undefined ? value : match
|
||||
})
|
||||
}
|
||||
@@ -676,6 +676,42 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Personnumret för ROT/RUT-avdraget är ogiltigt.',
|
||||
message_en: 'The personnummer provided for the ROT/RUT deduction is invalid.',
|
||||
},
|
||||
// Rot/rut begäran om utbetalning (Skatteverkets husavdragstjänst)
|
||||
ROT_RUT_REQUEST_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Begäran om utbetalning hittades inte.',
|
||||
message_en: 'Payout request not found.',
|
||||
},
|
||||
ROT_RUT_NO_ELIGIBLE_INVOICES: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Ingen av de valda fakturorna kan ingå i filen. Se blockeringarna per faktura.',
|
||||
message_en: 'None of the selected invoices can be included in the file. See the per-invoice blockers.',
|
||||
},
|
||||
ROT_RUT_INVOICES_BLOCKED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'En eller flera valda fakturor kan inte ingå i filen. Åtgärda blockeringarna eller välj bort fakturorna.',
|
||||
message_en: 'One or more selected invoices cannot be included in the file. Fix the blockers or deselect the invoices.',
|
||||
},
|
||||
ROT_RUT_INVOICE_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Minst en faktura ingår redan i en aktiv begäran om utbetalning.',
|
||||
message_en: 'At least one invoice is already part of an active payout request.',
|
||||
},
|
||||
ROT_RUT_INVALID_STATUS_TRANSITION: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Statusändringen är inte tillåten för begäran i dess nuvarande läge.',
|
||||
message_en: 'The status transition is not allowed from the request current state.',
|
||||
},
|
||||
ROT_RUT_SETTLE_INVALID_STATE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Utbetalningen kan bara bokföras för en inskickad begäran som inte redan är bokförd.',
|
||||
message_en: 'The payout can only be booked for a submitted request that is not already settled.',
|
||||
},
|
||||
ROT_RUT_FILE_CREATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Filen kunde inte skapas.',
|
||||
message_en: 'The payout file could not be created.',
|
||||
},
|
||||
INVOICE_CREATE_INSERT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Fakturan kunde inte sparas.',
|
||||
@@ -1489,6 +1525,11 @@ const DOCUMENT: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Filtypen stöds inte.',
|
||||
message_en: 'Unsupported file type.',
|
||||
},
|
||||
DOC_UPLOAD_INVALID_CONTENT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Filen kunde inte läsas som en giltig PDF eller bild. Kontrollera att filen inte är skadad.',
|
||||
message_en: 'The file could not be read as a valid PDF or image. Check that the file is not corrupted.',
|
||||
},
|
||||
DOC_UPLOAD_STORAGE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Filen kunde inte sparas.',
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ export type CoreEvent =
|
||||
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
||||
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
||||
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null // human-readable error message (truncated to 500 chars), null on success.
|
||||
// Raw material for clustering real agent failures into curated gotchas —
|
||||
// errorCode alone can't distinguish "period locked" from "unbalanced".
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Invoice, InvoiceItem } from '@/types'
|
||||
import { makeInvoice } from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import {
|
||||
buildRotRutFile,
|
||||
evaluateInvoiceForFile,
|
||||
isPastRequestDeadline,
|
||||
normalizeBrfOrgNr,
|
||||
} from '@/lib/invoices/rot-rut-file'
|
||||
|
||||
// Personnummer from Skatteverket's official example files (synthetic test
|
||||
// identities published by the agency — never real people).
|
||||
const PNR_A = '198406012388'
|
||||
const PNR_B = '199604102393'
|
||||
|
||||
const TODAY = '2026-07-02'
|
||||
|
||||
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
|
||||
return {
|
||||
id: 'item-1',
|
||||
invoice_id: 'invoice-1',
|
||||
sort_order: 0,
|
||||
description: 'Arbete',
|
||||
quantity: 1,
|
||||
unit: 'tim',
|
||||
unit_price: 10000,
|
||||
line_total: 10000,
|
||||
vat_rate: 25,
|
||||
vat_amount: 2500,
|
||||
deduction_type: 'rot',
|
||||
deduction_amount: 3000,
|
||||
labor_hours: 25,
|
||||
work_type: 'BYGG',
|
||||
housing_designation: 'Stockholm Vasastan 1:23',
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
created_at: '2026-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRotInvoice(overrides: Partial<Invoice> = {}, items?: InvoiceItem[]): Invoice {
|
||||
return makeInvoice({
|
||||
status: 'paid',
|
||||
paid_at: '2026-06-20T10:00:00Z',
|
||||
deduction_total: 3000,
|
||||
deduction_personnummer_encrypted: encryptPersonnummer(PNR_A),
|
||||
deduction_personnummer_last4: PNR_A.slice(-4),
|
||||
items: items ?? [makeItem()],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildRotRutFile — rot', () => {
|
||||
it('produces a schema-shaped rot file for a paid invoice', () => {
|
||||
const result = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'ROT 2026-07-02',
|
||||
invoices: [makeRotInvoice()],
|
||||
today: TODAY,
|
||||
})
|
||||
|
||||
expect(result.blockers).toHaveLength(0)
|
||||
expect(result.arenden).toHaveLength(1)
|
||||
expect(result.requested_total).toBe(3000)
|
||||
expect(result.file_name).toBe('rot_begaran_2026-07-02.xml')
|
||||
|
||||
const xml = result.xml!
|
||||
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
expect(xml).toContain('xmlns:ns1="http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0"')
|
||||
expect(xml).toContain('xmlns:ns2="http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0"')
|
||||
expect(xml).toContain('<ns2:NamnPaBegaran>ROT 2026-07-02</ns2:NamnPaBegaran>')
|
||||
expect(xml).toContain('<ns2:RotBegaran>')
|
||||
expect(xml).toContain(`<ns2:Kopare>${PNR_A}</ns2:Kopare>`)
|
||||
expect(xml).toContain('<ns2:BetalningsDatum>2026-06-20</ns2:BetalningsDatum>')
|
||||
expect(xml).toContain('<ns2:PrisForArbete>12500</ns2:PrisForArbete>')
|
||||
expect(xml).toContain('<ns2:BetaltBelopp>9500</ns2:BetaltBelopp>')
|
||||
expect(xml).toContain('<ns2:BegartBelopp>3000</ns2:BegartBelopp>')
|
||||
expect(xml).toContain('<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>')
|
||||
expect(xml).toContain('<ns2:Fastighetsbeteckning>Stockholm Vasastan 1:23</ns2:Fastighetsbeteckning>')
|
||||
expect(xml).toContain('<ns2:Bygg>')
|
||||
expect(xml).toContain('<ns2:AntalTimmar>25</ns2:AntalTimmar>')
|
||||
expect(xml).toContain('<ns2:Materialkostnad>0</ns2:Materialkostnad>')
|
||||
})
|
||||
|
||||
it('emits ärende elements in the XSD sequence order', () => {
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Ordning',
|
||||
invoices: [makeRotInvoice()],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
const order = [
|
||||
'Kopare',
|
||||
'BetalningsDatum',
|
||||
'PrisForArbete',
|
||||
'BetaltBelopp',
|
||||
'BegartBelopp',
|
||||
'FakturaNr',
|
||||
'Ovrigkostnad',
|
||||
'Fastighetsbeteckning',
|
||||
'UtfortArbete',
|
||||
]
|
||||
const positions = order.map((el) => xml.indexOf(`<ns2:${el}`))
|
||||
for (const pos of positions) expect(pos).toBeGreaterThan(-1)
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
expect(positions[i]).toBeGreaterThan(positions[i - 1])
|
||||
}
|
||||
})
|
||||
|
||||
it('aggregates hours per work type and orders work elements per XSD', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'i1', work_type: 'VVS', labor_hours: 3, line_total: 3000, vat_amount: 750, deduction_amount: 900 }),
|
||||
makeItem({ id: 'i2', work_type: 'EL', labor_hours: 2, line_total: 2000, vat_amount: 500, deduction_amount: 600 }),
|
||||
makeItem({ id: 'i3', work_type: 'VVS', labor_hours: 2.4, line_total: 1000, vat_amount: 250, deduction_amount: 300 }),
|
||||
]
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Aggregering',
|
||||
invoices: [makeRotInvoice({}, items)],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
// 3 + 2.4 h VVS → 5 (whole hours per XSD long)
|
||||
expect(xml).toMatch(/<ns2:Vvs>\s*<ns2:AntalTimmar>5<\/ns2:AntalTimmar>/)
|
||||
expect(xml).toMatch(/<ns2:El>\s*<ns2:AntalTimmar>2<\/ns2:AntalTimmar>/)
|
||||
// El precedes Vvs in the XSD sequence
|
||||
expect(xml.indexOf('<ns2:El>')).toBeLessThan(xml.indexOf('<ns2:Vvs>'))
|
||||
})
|
||||
|
||||
it('uses lägenhetsnummer + normalized BRF orgnr for bostadsrätt', () => {
|
||||
const items = [
|
||||
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '769600-0000' }),
|
||||
]
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Brf',
|
||||
invoices: [makeRotInvoice({}, items)],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
expect(xml).not.toContain('Fastighetsbeteckning')
|
||||
expect(xml).toContain('<ns2:LagenhetsNr>1101</ns2:LagenhetsNr>')
|
||||
expect(xml).toContain('<ns2:BrfOrgNr>167696000000</ns2:BrfOrgNr>')
|
||||
})
|
||||
|
||||
it('escapes XML special characters and clamps NamnPaBegaran to 16 chars', () => {
|
||||
const items = [makeItem({ housing_designation: 'Gränby 1:2 & "Södra" <3' })]
|
||||
const result = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Väldigt långt namn på begäran som klipps',
|
||||
invoices: [makeRotInvoice({ invoice_number: 'F<&>2026' }, items)],
|
||||
today: TODAY,
|
||||
})
|
||||
const xml = result.xml!
|
||||
|
||||
expect(xml).toContain('Gränby 1:2 & "Södra" <3')
|
||||
expect(xml).toContain('<ns2:FakturaNr>F<&>2026</ns2:FakturaNr>')
|
||||
const name = xml.match(/<ns2:NamnPaBegaran>(.*)<\/ns2:NamnPaBegaran>/)?.[1]
|
||||
expect(name).toBe('Väldigt långt na')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRotRutFile — rut', () => {
|
||||
function makeRutInvoice(items: InvoiceItem[]): Invoice {
|
||||
return makeRotInvoice(
|
||||
{ deduction_personnummer_encrypted: encryptPersonnummer(PNR_B) },
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
it('wraps ärenden in HushallBegaran and accepts IT-tjänster', () => {
|
||||
const items = [
|
||||
makeItem({ deduction_type: 'rut', work_type: 'IT', labor_hours: 4, housing_designation: null, deduction_amount: 5000 }),
|
||||
]
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rut',
|
||||
name: 'RUT juni',
|
||||
invoices: [makeRutInvoice(items)],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
expect(xml).toContain('<ns2:HushallBegaran>')
|
||||
expect(xml).not.toContain('RotBegaran')
|
||||
expect(xml).toMatch(/<ns2:ItTjanster>\s*<ns2:AntalTimmar>4<\/ns2:AntalTimmar>/)
|
||||
// No property elements for rut
|
||||
expect(xml).not.toContain('Fastighetsbeteckning')
|
||||
})
|
||||
|
||||
it('reports schablontjänster as Utfort without hours', () => {
|
||||
const items = [
|
||||
makeItem({ deduction_type: 'rut', work_type: 'TVATT', labor_hours: null, housing_designation: null, deduction_amount: 250 }),
|
||||
]
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rut',
|
||||
name: 'Schablon',
|
||||
invoices: [makeRutInvoice(items)],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
expect(xml).toMatch(/<ns2:TvattVidTvattinrattning>\s*<ns2:Utfort>true<\/ns2:Utfort>/)
|
||||
expect(xml).not.toContain('AntalTimmar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('eligibility blockers', () => {
|
||||
it('NOT_PAID for unpaid invoices', () => {
|
||||
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ status: 'sent' }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('NOT_PAID')
|
||||
})
|
||||
|
||||
it('MISSING_PAYMENT_DATE when paid without paid_at', () => {
|
||||
const result = evaluateInvoiceForFile('rot', makeRotInvoice({ paid_at: null }))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
|
||||
})
|
||||
|
||||
it('NO_DEDUCTION_OF_TYPE when the invoice has no lines of the requested type', () => {
|
||||
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
|
||||
})
|
||||
|
||||
it('MIXED_DEDUCTION_TYPES when rot and rut lines share an invoice', () => {
|
||||
const items = [
|
||||
makeItem(),
|
||||
makeItem({ id: 'i2', deduction_type: 'rut', work_type: 'STAD', labor_hours: 2 }),
|
||||
]
|
||||
const result = evaluateInvoiceForFile('rot', makeRotInvoice({}, items))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MIXED_DEDUCTION_TYPES')
|
||||
})
|
||||
|
||||
it('MISSING_PERSONNUMMER without an encrypted personnummer', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({ deduction_personnummer_encrypted: null }),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PERSONNUMMER')
|
||||
})
|
||||
|
||||
it('PERSONNUMMER_UNREADABLE on undecryptable ciphertext', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({ deduction_personnummer_encrypted: 'deadbeef' }),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('PERSONNUMMER_UNREADABLE')
|
||||
})
|
||||
|
||||
it('MISSING_WORK_TYPE when a deduction line has no arbetstyp', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [makeItem({ work_type: null })]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MISSING_WORK_TYPE')
|
||||
})
|
||||
|
||||
it('INVALID_WORK_TYPE for IT flagged as rot (rut-only service)', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [makeItem({ work_type: 'IT' })]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('INVALID_WORK_TYPE')
|
||||
})
|
||||
|
||||
it('MISSING_HOURS when a non-schablon line lacks labor hours', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [makeItem({ labor_hours: null })]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MISSING_HOURS')
|
||||
})
|
||||
|
||||
it('HOURS_OUT_OF_RANGE above 999 hours', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [makeItem({ labor_hours: 1200 })]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('HOURS_OUT_OF_RANGE')
|
||||
})
|
||||
|
||||
it('MISSING_PROPERTY when a rot invoice has no property info', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [makeItem({ housing_designation: null })]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PROPERTY')
|
||||
})
|
||||
|
||||
it('INVALID_BRF_ORGNR on a malformed BRF orgnr', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [
|
||||
makeItem({ housing_designation: null, apartment_number: '1101', brf_org_number: '123' }),
|
||||
]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('INVALID_BRF_ORGNR')
|
||||
})
|
||||
|
||||
it('PRICE_BELOW_MINIMUM when arbetskostnaden rounds below 2 kr', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [
|
||||
makeItem({ line_total: 1, vat_amount: 0, deduction_amount: 0.3, labor_hours: 1 }),
|
||||
]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('PRICE_BELOW_MINIMUM')
|
||||
})
|
||||
|
||||
it('ZERO_DEDUCTION when the deduction rounds to 0 kr', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [
|
||||
makeItem({ line_total: 100, vat_amount: 25, deduction_amount: 0, labor_hours: 1 }),
|
||||
]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('ZERO_DEDUCTION')
|
||||
})
|
||||
|
||||
it('DEDUCTION_EXCEEDS_PAYMENT when begärt belopp exceeds what the buyer paid', () => {
|
||||
// 100 kr work, 60 kr deduction → buyer paid 40 kr < 60 kr requested.
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rot',
|
||||
makeRotInvoice({}, [
|
||||
makeItem({ line_total: 80, vat_amount: 20, deduction_amount: 60, labor_hours: 1 }),
|
||||
]),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_EXCEEDS_PAYMENT')
|
||||
})
|
||||
|
||||
it('allows begärt belopp equal to betalt belopp (50 % rut)', () => {
|
||||
const result = evaluateInvoiceForFile(
|
||||
'rut',
|
||||
makeRotInvoice({}, [
|
||||
makeItem({
|
||||
deduction_type: 'rut',
|
||||
work_type: 'STAD',
|
||||
line_total: 80,
|
||||
vat_amount: 20,
|
||||
deduction_amount: 50,
|
||||
labor_hours: 2,
|
||||
housing_designation: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('collects blockers per invoice while still emitting eligible ones', () => {
|
||||
const result = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Blandat',
|
||||
invoices: [makeRotInvoice(), makeRotInvoice({ status: 'sent', invoice_number: 'F-BAD' })],
|
||||
today: TODAY,
|
||||
})
|
||||
expect(result.arenden).toHaveLength(1)
|
||||
expect(result.blockers).toHaveLength(1)
|
||||
expect(result.blockers[0].invoice_number).toBe('F-BAD')
|
||||
expect(result.xml).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns xml: null when nothing is eligible', () => {
|
||||
const result = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Tomt',
|
||||
invoices: [makeRotInvoice({ status: 'sent' })],
|
||||
today: TODAY,
|
||||
})
|
||||
expect(result.xml).toBeNull()
|
||||
expect(result.requested_total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deadline + helpers', () => {
|
||||
it('warns when the 31 January deadline has passed', () => {
|
||||
const invoice = makeRotInvoice({ paid_at: '2024-12-30T00:00:00Z' })
|
||||
const result = buildRotRutFile({ type: 'rot', name: 'Sen', invoices: [invoice], today: TODAY })
|
||||
expect(result.warnings).toHaveLength(1)
|
||||
expect(result.warnings[0]).toContain('31 januari')
|
||||
})
|
||||
|
||||
it('isPastRequestDeadline boundary behavior', () => {
|
||||
expect(isPastRequestDeadline('2025-06-01', '2026-01-31')).toBe(false)
|
||||
expect(isPastRequestDeadline('2025-06-01', '2026-02-01')).toBe(true)
|
||||
expect(isPastRequestDeadline('2026-01-15', '2026-07-02')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizeBrfOrgNr handles 10/12 digits and rejects the rest', () => {
|
||||
expect(normalizeBrfOrgNr('769600-0000')).toBe('167696000000')
|
||||
expect(normalizeBrfOrgNr('167696000000')).toBe('167696000000')
|
||||
expect(normalizeBrfOrgNr('76960')).toBeNull()
|
||||
// 12 digits without the sekelsiffra 16 prefix is not a valid orgnr.
|
||||
expect(normalizeBrfOrgNr('123456789012')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches the shape of Skatteverkets official rot example', () => {
|
||||
// Mirror exempel_rot_3st.xml ärende 2: fastighet + one work type.
|
||||
const items = [
|
||||
makeItem({
|
||||
work_type: 'GLAS_PLAT',
|
||||
labor_hours: 4,
|
||||
housing_designation: 'TEST 1:7',
|
||||
line_total: 1600,
|
||||
vat_amount: 400,
|
||||
deduction_amount: 600,
|
||||
}),
|
||||
]
|
||||
const xml = buildRotRutFile({
|
||||
type: 'rot',
|
||||
name: 'Exempel Rot',
|
||||
invoices: [makeRotInvoice({}, items)],
|
||||
today: TODAY,
|
||||
}).xml!
|
||||
|
||||
expect(xml).toMatch(
|
||||
/<ns2:Arenden>\s*<ns2:Kopare>\d{12}<\/ns2:Kopare>\s*<ns2:BetalningsDatum>\d{4}-\d{2}-\d{2}<\/ns2:BetalningsDatum>\s*<ns2:PrisForArbete>2000<\/ns2:PrisForArbete>\s*<ns2:BetaltBelopp>1400<\/ns2:BetaltBelopp>\s*<ns2:BegartBelopp>600<\/ns2:BegartBelopp>/,
|
||||
)
|
||||
expect(xml).toMatch(/<ns2:Fastighetsbeteckning>TEST 1:7<\/ns2:Fastighetsbeteckning>\s*<ns2:UtfortArbete>\s*<ns2:GlasPlatarbete>/)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ export interface InvoiceWriteItemInput {
|
||||
work_type?: string | null
|
||||
housing_designation?: string | null
|
||||
apartment_number?: string | null
|
||||
brf_org_number?: string | null
|
||||
accrual_period_start?: string | null
|
||||
accrual_period_end?: string | null
|
||||
accrual_balance_account?: string | null
|
||||
@@ -71,6 +72,9 @@ export interface InvoiceWriteInput {
|
||||
ore_rounding?: boolean
|
||||
deduction_personnummer?: string
|
||||
deduction_housing_designation?: string
|
||||
/** ROT i bostadsrätt: lägenhetsnummer + föreningens orgnr instead of fastighetsbeteckning. */
|
||||
deduction_apartment_number?: string
|
||||
deduction_brf_org_number?: string
|
||||
/** Dimensions PR7: invoice-level bag applied to every generated journal line. */
|
||||
default_dimensions?: Record<string, string>
|
||||
items: InvoiceWriteItemInput[]
|
||||
@@ -127,6 +131,7 @@ export type InvoiceWriteItemRow = {
|
||||
work_type: string | null
|
||||
housing_designation: string | null
|
||||
apartment_number: string | null
|
||||
brf_org_number: string | null
|
||||
accrual_period_start: string | null
|
||||
accrual_period_end: string | null
|
||||
accrual_balance_account: string | null
|
||||
@@ -260,7 +265,23 @@ export async function buildInvoiceWriteData(params: {
|
||||
let deductionPersonnummerEncrypted: string | null = null
|
||||
let deductionPersonnummerLast4: string | null = null
|
||||
if (documentType === 'invoice') {
|
||||
const housingProvided = !!input.deduction_housing_designation?.trim()
|
||||
// Housing info satisfies the ROT requirement in either of two shapes
|
||||
// (Begaran.xsd V6): fastighetsbeteckning (småhus/ägarlägenhet) OR
|
||||
// lägenhetsnummer + bostadsrättsföreningens orgnr (bostadsrätt).
|
||||
const fastighetProvided = !!input.deduction_housing_designation?.trim()
|
||||
const apartmentProvided = !!input.deduction_apartment_number?.trim()
|
||||
const brfProvided = !!input.deduction_brf_org_number?.trim()
|
||||
if ((apartmentProvided || brfProvided) && !(apartmentProvided && brfProvided)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVOICE_CREATE_ROT_RUT_VALIDATION',
|
||||
details: {
|
||||
errors: ['För bostadsrätt krävs både lägenhetsnummer och föreningens organisationsnummer.'],
|
||||
warnings: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
const housingProvided = fastighetProvided || (apartmentProvided && brfProvided)
|
||||
const personnummerRaw = input.deduction_personnummer?.trim() || ''
|
||||
const personnummerProvided = personnummerRaw.length > 0
|
||||
|
||||
@@ -377,6 +398,7 @@ export async function buildInvoiceWriteData(params: {
|
||||
work_type: null,
|
||||
housing_designation: null,
|
||||
apartment_number: null,
|
||||
brf_org_number: null,
|
||||
accrual_period_start: null,
|
||||
accrual_period_end: null,
|
||||
accrual_balance_account: null,
|
||||
@@ -416,8 +438,22 @@ export async function buildInvoiceWriteData(params: {
|
||||
deduction_amount: deductionAmount,
|
||||
labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
|
||||
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
|
||||
housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
|
||||
apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
|
||||
// Property info: per-line value wins, else the invoice-level claim-card
|
||||
// value is stamped onto every deduction line so the Skatteverket file
|
||||
// generator can read it off the line later. Non-deduction lines carry
|
||||
// no property data (privacy by default).
|
||||
housing_designation:
|
||||
documentType === 'invoice' && deductionType
|
||||
? (item.housing_designation ?? input.deduction_housing_designation?.trim() ?? null) || null
|
||||
: null,
|
||||
apartment_number:
|
||||
documentType === 'invoice' && deductionType
|
||||
? (item.apartment_number ?? input.deduction_apartment_number?.trim() ?? null) || null
|
||||
: null,
|
||||
brf_org_number:
|
||||
documentType === 'invoice' && deductionType
|
||||
? (item.brf_org_number ?? input.deduction_brf_org_number?.trim() ?? null) || null
|
||||
: null,
|
||||
// Periodisering (förutbetald intäkt): frozen onto the line. The schedule
|
||||
// itself is created when the invoice is sent/booked. ROT/RUT lines never
|
||||
// defer (schema-enforced); the guard above restricted this to real
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import type { Invoice, InvoiceItem } from '@/types'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import type { DeductionType } from './rot-rut-rules'
|
||||
|
||||
/**
|
||||
* Begäran om utbetalning — rot & rut (Skatteverkets husavdragstjänst).
|
||||
*
|
||||
* Generates the HUS XML file (schema V6) that is uploaded manually on
|
||||
* Skatteverkets e-tjänst "Rot och rut – företag" → "Begär utbetalning via
|
||||
* fil". There is NO submission API — the file replaces per-ärende manual
|
||||
* entry, the upload + signature (e-legitimation) stays with the user.
|
||||
*
|
||||
* Schema (vendored in dev_docs/skatteverket/husavdrag/):
|
||||
* root: http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0
|
||||
* types: http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0
|
||||
*
|
||||
* Hard schema facts honoured here:
|
||||
* - Rot and rut can NEVER be mixed in one file (choice of RotBegaran |
|
||||
* HushallBegaran). One file per deduction type.
|
||||
* - All amounts are whole kronor (xs:long). PrisForArbete min 2.
|
||||
* - Kopare is a 12-digit personnummer.
|
||||
* - NamnPaBegaran is 1–16 characters.
|
||||
* - Element order inside an ärende is fixed: base fields, then (rot only)
|
||||
* property fields, then UtfortArbete.
|
||||
* - Ovrigkostnad is mandatory as soon as UtfortArbete reports hours or
|
||||
* material. Accounted doesn't itemize övrig kostnad (travel/machines) —
|
||||
* materials live on non-deduction rows — so 0 is emitted.
|
||||
*
|
||||
* Everything in this module is pure and deterministic: the caller passes the
|
||||
* invoices and `today`; blockers are named per invoice, never guessed.
|
||||
* File content stays Swedish (statutory surface, see .claude/rules/i18n.md).
|
||||
*/
|
||||
|
||||
const ROOT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/begaran/6.0'
|
||||
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/ht/komponent/begaran/6.0'
|
||||
|
||||
/**
|
||||
* work_type code → XSD element. Array order = the XSD sequence order, which
|
||||
* is also the emission order inside UtfortArbete. `schablon` services are
|
||||
* reported as <Utfort>true</Utfort> — no hours, no material.
|
||||
*/
|
||||
const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
|
||||
code: string
|
||||
element: string
|
||||
schablon?: boolean
|
||||
}>> = {
|
||||
rot: [
|
||||
{ code: 'BYGG', element: 'Bygg' },
|
||||
{ code: 'EL', element: 'El' },
|
||||
{ code: 'GLAS_PLAT', element: 'GlasPlatarbete' },
|
||||
{ code: 'MARK_DRAN', element: 'MarkDraneringarbete' },
|
||||
{ code: 'MURNING', element: 'Murning' },
|
||||
{ code: 'MALNING', element: 'MalningTapetsering' },
|
||||
{ code: 'VVS', element: 'Vvs' },
|
||||
],
|
||||
rut: [
|
||||
{ code: 'STAD', element: 'Stadning' },
|
||||
{ code: 'KLAD', element: 'KladOchTextilvard' },
|
||||
{ code: 'SNOSKOTTNING', element: 'Snoskottning' },
|
||||
{ code: 'TRADGARD', element: 'Tradgardsarbete' },
|
||||
{ code: 'BARNPASS', element: 'Barnpassning' },
|
||||
{ code: 'PERSONLIG_OMS', element: 'Personligomsorg' },
|
||||
{ code: 'FLYTT', element: 'Flyttjanster' },
|
||||
{ code: 'IT', element: 'ItTjanster' },
|
||||
{ code: 'REPARATION', element: 'ReparationAvVitvaror' },
|
||||
{ code: 'MOBLERING', element: 'Moblering' },
|
||||
{ code: 'TILLSYN', element: 'TillsynAvBostad' },
|
||||
{ code: 'TRANSPORT', element: 'TransportTillForsaljning', schablon: true },
|
||||
{ code: 'TVATT', element: 'TvattVidTvattinrattning', schablon: true },
|
||||
],
|
||||
}
|
||||
|
||||
export type RotRutBlockerCode =
|
||||
| 'NOT_PAID'
|
||||
| 'MISSING_PAYMENT_DATE'
|
||||
| 'NO_DEDUCTION_OF_TYPE'
|
||||
| 'MIXED_DEDUCTION_TYPES'
|
||||
| 'MISSING_PERSONNUMMER'
|
||||
| 'PERSONNUMMER_UNREADABLE'
|
||||
| 'MISSING_WORK_TYPE'
|
||||
| 'INVALID_WORK_TYPE'
|
||||
| 'MISSING_HOURS'
|
||||
| 'HOURS_OUT_OF_RANGE'
|
||||
| 'MISSING_PROPERTY'
|
||||
| 'INVALID_BRF_ORGNR'
|
||||
| 'PROPERTY_TOO_LONG'
|
||||
| 'PRICE_BELOW_MINIMUM'
|
||||
| 'DEDUCTION_EXCEEDS_PAYMENT'
|
||||
| 'ZERO_DEDUCTION'
|
||||
|
||||
export interface RotRutBlocker {
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
code: RotRutBlockerCode
|
||||
/** Swedish — shown as-is in UI and MCP output (statutory surface). */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** One ärende (buyer + invoice) accepted into the file. */
|
||||
export interface RotRutArende {
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
personnummer_last4: string
|
||||
betalnings_datum: string
|
||||
/** Whole kronor, as emitted. */
|
||||
pris_for_arbete: number
|
||||
betalt_belopp: number
|
||||
begart_belopp: number
|
||||
}
|
||||
|
||||
export interface BuildRotRutFileResult {
|
||||
/** null when no invoice passed eligibility. */
|
||||
xml: string | null
|
||||
file_name: string
|
||||
arenden: RotRutArende[]
|
||||
blockers: RotRutBlocker[]
|
||||
/** Non-blocking notices (e.g. deadline passed). Swedish. */
|
||||
warnings: string[]
|
||||
/** Sum of begart_belopp, whole kronor. */
|
||||
requested_total: number
|
||||
}
|
||||
|
||||
interface EvaluatedArende {
|
||||
arende: RotRutArende
|
||||
/** Emission-ready fragments, in XSD order. */
|
||||
kopare: string
|
||||
fakturaNr: string | null
|
||||
property: { fastighet?: string; lagenhetsNr?: string; brfOrgNr?: string } | null
|
||||
/** element name → { hours, schablon } aggregated over lines. */
|
||||
work: Array<{ element: string; schablon: boolean; hours: number }>
|
||||
}
|
||||
|
||||
function isDeductionLine(item: InvoiceItem, type: DeductionType): boolean {
|
||||
return item.deduction_type === type && item.line_type !== 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a BRF orgnr to the 12-digit form Skatteverkets exempel uses
|
||||
* (sekelsiffra 16 + 10-digit orgnr). Returns null when the input can't be
|
||||
* normalized deterministically.
|
||||
*/
|
||||
export function normalizeBrfOrgNr(raw: string): string | null {
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
// 12-digit orgnr must carry sekelsiffra 16 (juridisk person) — anything
|
||||
// else is not a valid Swedish orgnr and fails SKV's schema at upload.
|
||||
if (digits.length === 12) return digits.startsWith('16') ? digits : null
|
||||
if (digits.length === 10) return `16${digits}`
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate one invoice against the file rules for `type`. Returns either an
|
||||
* emission-ready ärende or the FIRST blocker hit (one clear reason beats a
|
||||
* pile). Exported so the eligible-list API can show per-invoice reasons with
|
||||
* exactly the same logic that later generates the file.
|
||||
*/
|
||||
export function evaluateInvoiceForFile(
|
||||
type: DeductionType,
|
||||
invoice: Invoice,
|
||||
): { ok: true; value: EvaluatedArende } | { ok: false; blocker: RotRutBlocker } {
|
||||
const block = (code: RotRutBlockerCode, message: string): { ok: false; blocker: RotRutBlocker } => ({
|
||||
ok: false,
|
||||
blocker: { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? null, code, message },
|
||||
})
|
||||
|
||||
const items = invoice.items ?? []
|
||||
const typeLines = items.filter((i) => isDeductionLine(i, type))
|
||||
const otherType: DeductionType = type === 'rot' ? 'rut' : 'rot'
|
||||
const otherLines = items.filter((i) => isDeductionLine(i, otherType))
|
||||
|
||||
if (typeLines.length === 0) {
|
||||
return block('NO_DEDUCTION_OF_TYPE', `Fakturan har inga ${type.toUpperCase()}-rader.`)
|
||||
}
|
||||
// One invoice must map to exactly one ärende in exactly one file. Mixed
|
||||
// rot+rut invoices would need to live in two active begäran at once, which
|
||||
// the double-request guard (rightly) refuses — ask the user to split.
|
||||
if (otherLines.length > 0) {
|
||||
return block(
|
||||
'MIXED_DEDUCTION_TYPES',
|
||||
'Fakturan blandar ROT- och RUT-rader. Skatteverket tillåter inte båda i samma fil — dela upp i separata fakturor.',
|
||||
)
|
||||
}
|
||||
|
||||
if (invoice.status !== 'paid') {
|
||||
return block('NOT_PAID', 'Kunden måste ha betalat sin del av fakturan innan utbetalning kan begäras.')
|
||||
}
|
||||
const paidDate = invoice.paid_at ? String(invoice.paid_at).slice(0, 10) : null
|
||||
if (!paidDate) {
|
||||
return block('MISSING_PAYMENT_DATE', 'Fakturan saknar betalningsdatum.')
|
||||
}
|
||||
|
||||
if (!invoice.deduction_personnummer_encrypted) {
|
||||
return block('MISSING_PERSONNUMMER', 'Fakturan saknar köparens personnummer.')
|
||||
}
|
||||
let kopare: string
|
||||
try {
|
||||
kopare = decryptPersonnummer(invoice.deduction_personnummer_encrypted).replace(/\D/g, '')
|
||||
} catch {
|
||||
return block('PERSONNUMMER_UNREADABLE', 'Köparens personnummer kunde inte läsas — öppna fakturautkastet och ange det igen.')
|
||||
}
|
||||
if (kopare.length !== 12) {
|
||||
return block('PERSONNUMMER_UNREADABLE', 'Köparens personnummer är inte 12 siffror.')
|
||||
}
|
||||
|
||||
// Aggregate hours per work type, in XSD element order.
|
||||
const elementMap = WORK_TYPE_ELEMENTS[type]
|
||||
const hoursByCode = new Map<string, number>()
|
||||
for (const line of typeLines) {
|
||||
const code = line.work_type ?? ''
|
||||
if (!code) {
|
||||
return block('MISSING_WORK_TYPE', 'Alla ROT/RUT-rader måste ha en arbetstyp. Öppna fakturan och välj arbetstyp per rad.')
|
||||
}
|
||||
const def = elementMap.find((e) => e.code === code)
|
||||
if (!def) {
|
||||
return block(
|
||||
'INVALID_WORK_TYPE',
|
||||
`Arbetstypen "${code}" är inte giltig för ${type.toUpperCase()} enligt Skatteverkets filformat.`,
|
||||
)
|
||||
}
|
||||
if (!def.schablon) {
|
||||
const hours = line.labor_hours ?? 0
|
||||
if (hours <= 0) {
|
||||
return block('MISSING_HOURS', 'Alla ROT/RUT-rader måste ha antal arbetstimmar (schablontjänster undantagna).')
|
||||
}
|
||||
hoursByCode.set(code, (hoursByCode.get(code) ?? 0) + hours)
|
||||
} else {
|
||||
hoursByCode.set(code, hoursByCode.get(code) ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
const work: EvaluatedArende['work'] = []
|
||||
for (const def of elementMap) {
|
||||
if (!hoursByCode.has(def.code)) continue
|
||||
const hours = Math.round(hoursByCode.get(def.code) ?? 0)
|
||||
if (!def.schablon && (hours < 1 || hours > 999)) {
|
||||
return block('HOURS_OUT_OF_RANGE', `Antal timmar för ${def.element} måste vara 1–999 (är ${hours}).`)
|
||||
}
|
||||
work.push({ element: def.element, schablon: def.schablon === true, hours })
|
||||
}
|
||||
|
||||
// Property info (rot only): fastighetsbeteckning OR lägenhetsnummer + BRF
|
||||
// orgnr, read off the rot lines (stamped there at save time).
|
||||
let property: EvaluatedArende['property'] = null
|
||||
if (type === 'rot') {
|
||||
const fastighet = typeLines.map((l) => l.housing_designation?.trim()).find(Boolean) ?? null
|
||||
const lagenhet = typeLines.map((l) => l.apartment_number?.trim()).find(Boolean) ?? null
|
||||
const brfRaw = typeLines.map((l) => l.brf_org_number?.trim()).find(Boolean) ?? null
|
||||
|
||||
if (brfRaw && lagenhet) {
|
||||
const brf = normalizeBrfOrgNr(brfRaw)
|
||||
if (!brf) {
|
||||
return block('INVALID_BRF_ORGNR', `Föreningens organisationsnummer "${brfRaw}" är ogiltigt (10 eller 12 siffror krävs).`)
|
||||
}
|
||||
if (lagenhet.length > 25) {
|
||||
return block('PROPERTY_TOO_LONG', 'Lägenhetsnumret är längre än 25 tecken.')
|
||||
}
|
||||
property = { lagenhetsNr: lagenhet, brfOrgNr: brf }
|
||||
} else if (fastighet) {
|
||||
if (fastighet.length > 40) {
|
||||
return block('PROPERTY_TOO_LONG', 'Fastighetsbeteckningen är längre än 40 tecken (Skatteverkets maxlängd).')
|
||||
}
|
||||
property = { fastighet }
|
||||
} else {
|
||||
return block(
|
||||
'MISSING_PROPERTY',
|
||||
'ROT kräver fastighetsbeteckning eller lägenhetsnummer + föreningens orgnr. Komplettera fakturan.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Amounts, whole kronor. PrisForArbete = arbetskostnad inkl moms for the
|
||||
// flagged lines; BegartBelopp mirrors the deduction the invoice actually
|
||||
// credited (1513); BetaltBelopp = what the buyer paid for the work.
|
||||
const prisForArbete = Math.round(
|
||||
typeLines.reduce((sum, l) => sum + (l.line_total ?? 0) + (l.vat_amount ?? 0), 0),
|
||||
)
|
||||
const begartBelopp = Math.round(
|
||||
typeLines.reduce((sum, l) => sum + (l.deduction_amount ?? 0), 0),
|
||||
)
|
||||
const betaltBelopp = prisForArbete - begartBelopp
|
||||
if (prisForArbete < 2) {
|
||||
return block('PRICE_BELOW_MINIMUM', 'Arbetskostnaden måste vara minst 2 kr (Skatteverkets filformat).')
|
||||
}
|
||||
// A zero-kronor ärende is rejected (or silently ignored) by Skatteverket —
|
||||
// an invoice whose deduction rounds to 0 has nothing to request.
|
||||
if (begartBelopp < 1) {
|
||||
return block('ZERO_DEDUCTION', 'Fakturans ROT/RUT-avdrag är 0 kr — det finns inget belopp att begära.')
|
||||
}
|
||||
// The buyer must have paid at least as much as is being requested
|
||||
// (skattereduktionen är max 50 % av arbetskostnaden). Independent rounding
|
||||
// of pris/begärt could otherwise even push BetaltBelopp negative, which
|
||||
// Skatteverkets schema rejects outright.
|
||||
if (begartBelopp > betaltBelopp) {
|
||||
return block(
|
||||
'DEDUCTION_EXCEEDS_PAYMENT',
|
||||
`Begärt belopp (${begartBelopp} kr) överstiger vad kunden betalat för arbetet (${betaltBelopp} kr) — Skatteverket avslår. Kontrollera avdragsraderna.`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
arende: {
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoice_number ?? null,
|
||||
personnummer_last4: kopare.slice(-4),
|
||||
betalnings_datum: paidDate,
|
||||
pris_for_arbete: prisForArbete,
|
||||
betalt_belopp: betaltBelopp,
|
||||
begart_belopp: begartBelopp,
|
||||
},
|
||||
kopare,
|
||||
fakturaNr: invoice.invoice_number ? String(invoice.invoice_number).slice(0, 20) : null,
|
||||
property,
|
||||
work,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* The 31 January deadline: a begäran must reach Skatteverket no later than
|
||||
* 31 January the year AFTER the buyer paid. Returns true when `paidDate`'s
|
||||
* window has closed as of `today` (both YYYY-MM-DD).
|
||||
*/
|
||||
export function isPastRequestDeadline(paidDate: string, today: string): boolean {
|
||||
const paidYear = Number(paidDate.slice(0, 4))
|
||||
const deadline = `${paidYear + 1}-01-31`
|
||||
return today > deadline
|
||||
}
|
||||
|
||||
export function buildRotRutFile(params: {
|
||||
type: DeductionType
|
||||
/** NamnPaBegaran — clamped to the XSD's 16-char cap. */
|
||||
name: string
|
||||
invoices: Invoice[]
|
||||
/** YYYY-MM-DD, injected for determinism. */
|
||||
today: string
|
||||
}): BuildRotRutFileResult {
|
||||
const { type, invoices, today } = params
|
||||
|
||||
const arenden: RotRutArende[] = []
|
||||
const evaluated: EvaluatedArende[] = []
|
||||
const blockers: RotRutBlocker[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const result = evaluateInvoiceForFile(type, invoice)
|
||||
if (!result.ok) {
|
||||
blockers.push(result.blocker)
|
||||
continue
|
||||
}
|
||||
evaluated.push(result.value)
|
||||
arenden.push(result.value.arende)
|
||||
if (isPastRequestDeadline(result.value.arende.betalnings_datum, today)) {
|
||||
warnings.push(
|
||||
`Faktura ${result.value.arende.invoice_number ?? result.value.arende.invoice_id}: betalningen (${result.value.arende.betalnings_datum}) har passerat sista begäransdatum (31 januari året efter betalningsåret). Skatteverket kan avslå.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${type}_begaran_${today}.xml`
|
||||
|
||||
if (evaluated.length === 0) {
|
||||
return { xml: null, file_name: fileName, arenden, blockers, warnings, requested_total: 0 }
|
||||
}
|
||||
|
||||
const name = escapeXml(params.name.slice(0, 16))
|
||||
const wrapper = type === 'rot' ? 'RotBegaran' : 'HushallBegaran'
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
lines.push(
|
||||
`<ns1:Begaran xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:ns1="${ROOT_NS}" xmlns:ns2="${KOMPONENT_NS}">`,
|
||||
)
|
||||
lines.push(`\t<ns2:NamnPaBegaran>${name}</ns2:NamnPaBegaran>`)
|
||||
lines.push(`\t<ns2:${wrapper}>`)
|
||||
|
||||
for (const ev of evaluated) {
|
||||
lines.push('\t\t<ns2:Arenden>')
|
||||
lines.push(`\t\t\t<ns2:Kopare>${ev.kopare}</ns2:Kopare>`)
|
||||
lines.push(`\t\t\t<ns2:BetalningsDatum>${ev.arende.betalnings_datum}</ns2:BetalningsDatum>`)
|
||||
lines.push(`\t\t\t<ns2:PrisForArbete>${ev.arende.pris_for_arbete}</ns2:PrisForArbete>`)
|
||||
lines.push(`\t\t\t<ns2:BetaltBelopp>${ev.arende.betalt_belopp}</ns2:BetaltBelopp>`)
|
||||
lines.push(`\t\t\t<ns2:BegartBelopp>${ev.arende.begart_belopp}</ns2:BegartBelopp>`)
|
||||
if (ev.fakturaNr) {
|
||||
lines.push(`\t\t\t<ns2:FakturaNr>${escapeXml(ev.fakturaNr)}</ns2:FakturaNr>`)
|
||||
}
|
||||
// Mandatory when UtfortArbete reports hours; Accounted books övriga
|
||||
// kostnader (resor, maskiner) outside the deduction rows → always 0.
|
||||
lines.push('\t\t\t<ns2:Ovrigkostnad>0</ns2:Ovrigkostnad>')
|
||||
if (ev.property?.fastighet) {
|
||||
lines.push(`\t\t\t<ns2:Fastighetsbeteckning>${escapeXml(ev.property.fastighet)}</ns2:Fastighetsbeteckning>`)
|
||||
}
|
||||
if (ev.property?.lagenhetsNr) {
|
||||
lines.push(`\t\t\t<ns2:LagenhetsNr>${escapeXml(ev.property.lagenhetsNr)}</ns2:LagenhetsNr>`)
|
||||
lines.push(`\t\t\t<ns2:BrfOrgNr>${ev.property.brfOrgNr}</ns2:BrfOrgNr>`)
|
||||
}
|
||||
if (ev.work.length > 0) {
|
||||
lines.push('\t\t\t<ns2:UtfortArbete>')
|
||||
for (const w of ev.work) {
|
||||
if (w.schablon) {
|
||||
lines.push(`\t\t\t\t<ns2:${w.element}>`)
|
||||
lines.push('\t\t\t\t\t<ns2:Utfort>true</ns2:Utfort>')
|
||||
lines.push(`\t\t\t\t</ns2:${w.element}>`)
|
||||
} else {
|
||||
lines.push(`\t\t\t\t<ns2:${w.element}>`)
|
||||
lines.push(`\t\t\t\t\t<ns2:AntalTimmar>${w.hours}</ns2:AntalTimmar>`)
|
||||
// Materials are invoiced on non-deduction rows in Accounted's
|
||||
// fakturamodell — the file reports 0 (XSD requires the element).
|
||||
lines.push('\t\t\t\t\t<ns2:Materialkostnad>0</ns2:Materialkostnad>')
|
||||
lines.push(`\t\t\t\t</ns2:${w.element}>`)
|
||||
}
|
||||
}
|
||||
lines.push('\t\t\t</ns2:UtfortArbete>')
|
||||
}
|
||||
lines.push('\t\t</ns2:Arenden>')
|
||||
}
|
||||
|
||||
lines.push(`\t</ns2:${wrapper}>`)
|
||||
lines.push('</ns1:Begaran>')
|
||||
|
||||
const requestedTotal = arenden.reduce((sum, a) => sum + a.begart_belopp, 0)
|
||||
|
||||
return {
|
||||
xml: lines.join('\n'),
|
||||
file_name: fileName,
|
||||
arenden,
|
||||
blockers,
|
||||
warnings,
|
||||
requested_total: requestedTotal,
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,13 @@ export const RUT_MAX = 75000
|
||||
export type DeductionType = 'rot' | 'rut'
|
||||
|
||||
/** Skatteverket work codes used by Husavdragstjänsten. Maps a free-text */
|
||||
/** "what the worker did" label to the official code the Skatteverket file */
|
||||
/** will need. v1 stores both — the label is shown on the PDF; the code is */
|
||||
/** stored as `work_type` for the future submission file. */
|
||||
/** "what the worker did" label to the official code. The code drives which */
|
||||
/** element the begäran-om-utbetalning file (Begaran.xsd V6) reports the */
|
||||
/** hours under — see WORK_TYPE_ELEMENTS in lib/invoices/rot-rut-file.ts. */
|
||||
/** The lists mirror the XSD exactly: rot work types are the seven */
|
||||
/** ArendeUtfortArbeteRotTYPE elements (IT-tjänster is a RUT service and was */
|
||||
/** removed from the rot list 2026-07); rut covers all thirteen */
|
||||
/** ArendeUtfortArbeteRutTYPE elements incl. the two schablontjänster. */
|
||||
export const ROT_WORK_TYPES = [
|
||||
{ code: 'BYGG', label: 'Byggnadsarbete' },
|
||||
{ code: 'EL', label: 'Elarbete' },
|
||||
@@ -48,20 +52,24 @@ export const ROT_WORK_TYPES = [
|
||||
{ code: 'MURNING', label: 'Murnings- och putsarbete' },
|
||||
{ code: 'MALNING', label: 'Mål- och tapetseringsarbete' },
|
||||
{ code: 'VVS', label: 'VVS-arbete' },
|
||||
{ code: 'IT', label: 'IT-tjänster i hemmet' },
|
||||
] as const
|
||||
|
||||
export const RUT_WORK_TYPES = [
|
||||
{ code: 'STAD', label: 'Städning, tvätt och vård av kläder' },
|
||||
{ code: 'KLAD', label: 'Klädvård i hemmet' },
|
||||
{ code: 'STAD', label: 'Städning' },
|
||||
{ code: 'KLAD', label: 'Kläd- och textilvård' },
|
||||
{ code: 'SNOSKOTTNING', label: 'Snöskottning' },
|
||||
{ code: 'TRADGARD', label: 'Trädgårdsarbete' },
|
||||
{ code: 'BARNPASS', label: 'Barnpassning' },
|
||||
{ code: 'PERSONLIG_OMS', label: 'Personlig omsorg' },
|
||||
{ code: 'FLYTT', label: 'Flytthjälp' },
|
||||
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
|
||||
{ code: 'FLYTT', label: 'Flyttjänster' },
|
||||
{ code: 'IT', label: 'IT-tjänster i hemmet' },
|
||||
{ code: 'MOBLERING', label: 'Möblering och tillsyn av bostad' },
|
||||
{ code: 'TRANSPORT', label: 'Transport till och från återvinning' },
|
||||
{ code: 'REPARATION', label: 'Reparation av vitvaror' },
|
||||
{ code: 'MOBLERING', label: 'Möblering' },
|
||||
{ code: 'TILLSYN', label: 'Tillsyn av bostad' },
|
||||
// Schablontjänster: reported as utförd/ej utförd in the Skatteverket file,
|
||||
// never with hours or material.
|
||||
{ code: 'TRANSPORT', label: 'Transport till försäljning (schablon)' },
|
||||
{ code: 'TVATT', label: 'Tvätt vid tvättinrättning (schablon)' },
|
||||
] as const
|
||||
|
||||
export interface ItemForDeduction {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Invoice } from '@/types'
|
||||
import {
|
||||
buildRotRutFile,
|
||||
evaluateInvoiceForFile,
|
||||
type BuildRotRutFileResult,
|
||||
type RotRutBlocker,
|
||||
} from './rot-rut-file'
|
||||
import type { DeductionType } from './rot-rut-rules'
|
||||
|
||||
/**
|
||||
* Shared service behind the rot/rut payout-file API routes and the MCP tool
|
||||
* (gnubok_generate_rot_rut_file) — one implementation of "which invoices can
|
||||
* go into a begäran" and "record the begäran", so the two surfaces can never
|
||||
* drift apart.
|
||||
*/
|
||||
|
||||
export interface RotRutCandidateSummary {
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
customer_name: string | null
|
||||
personnummer_last4: string
|
||||
betalnings_datum: string
|
||||
pris_for_arbete: number
|
||||
begart_belopp: number
|
||||
}
|
||||
|
||||
export interface RotRutBlockedSummary {
|
||||
invoice_id: string
|
||||
invoice_number: string | null
|
||||
customer_name: string | null
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type InvoiceWithCustomer = Invoice & { customer?: { name?: string | null } | null }
|
||||
|
||||
/**
|
||||
* Paid deduction-carrying invoices not yet claimed by an active begäran,
|
||||
* evaluated against the file rules. Invoices whose deduction belongs solely
|
||||
* to the other type are omitted entirely (they're the other list's business).
|
||||
*/
|
||||
export async function listRotRutCandidates(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
type: DeductionType,
|
||||
): Promise<
|
||||
| { ok: true; eligible: RotRutCandidateSummary[]; blocked: RotRutBlockedSummary[] }
|
||||
| { ok: false; dbError: unknown }
|
||||
> {
|
||||
const { data: invoices, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*), customer:customers(id, name)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.eq('status', 'paid')
|
||||
.gt('deduction_total', 0)
|
||||
.order('paid_at', { ascending: true })
|
||||
|
||||
if (error) return { ok: false, dbError: error }
|
||||
|
||||
const { data: activeItems, error: activeError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('invoice_id, request:rot_rut_payout_requests!inner(id, status, company_id)')
|
||||
.eq('request.company_id', companyId)
|
||||
.not('request.status', 'in', '("cancelled","rejected")')
|
||||
|
||||
if (activeError) return { ok: false, dbError: activeError }
|
||||
const activeInvoiceIds = new Set((activeItems ?? []).map((r) => r.invoice_id))
|
||||
|
||||
const eligible: RotRutCandidateSummary[] = []
|
||||
const blocked: RotRutBlockedSummary[] = []
|
||||
|
||||
for (const invoice of (invoices ?? []) as unknown as InvoiceWithCustomer[]) {
|
||||
if (activeInvoiceIds.has(invoice.id)) continue
|
||||
|
||||
const result = evaluateInvoiceForFile(type, invoice)
|
||||
if (result.ok) {
|
||||
eligible.push({
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoice_number ?? null,
|
||||
customer_name: invoice.customer?.name ?? null,
|
||||
personnummer_last4: result.value.arende.personnummer_last4,
|
||||
betalnings_datum: result.value.arende.betalnings_datum,
|
||||
pris_for_arbete: result.value.arende.pris_for_arbete,
|
||||
begart_belopp: result.value.arende.begart_belopp,
|
||||
})
|
||||
} else if (result.blocker.code !== 'NO_DEDUCTION_OF_TYPE') {
|
||||
blocked.push({
|
||||
invoice_id: invoice.id,
|
||||
invoice_number: invoice.invoice_number ?? null,
|
||||
customer_name: invoice.customer?.name ?? null,
|
||||
code: result.blocker.code,
|
||||
message: result.blocker.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, eligible, blocked }
|
||||
}
|
||||
|
||||
export type CreateRotRutRequestResult =
|
||||
| { ok: true; request: Record<string, unknown>; file: BuildRotRutFileResult }
|
||||
| {
|
||||
ok: false
|
||||
code:
|
||||
| 'ROT_RUT_REQUEST_NOT_FOUND'
|
||||
| 'ROT_RUT_NO_ELIGIBLE_INVOICES'
|
||||
| 'ROT_RUT_INVOICES_BLOCKED'
|
||||
| 'ROT_RUT_INVOICE_CONFLICT'
|
||||
| 'ROT_RUT_FILE_CREATE_FAILED'
|
||||
blockers?: RotRutBlocker[]
|
||||
missingInvoiceIds?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the begäran file for the given invoices and record the request +
|
||||
* items. All-or-nothing: any blocked invoice rejects the whole call with the
|
||||
* per-invoice blockers. The DB trigger enforce_single_active_rot_rut_request
|
||||
* stays the authoritative double-request guard (surfaced as INVOICE_CONFLICT).
|
||||
*
|
||||
* Document archiving is deliberately NOT done here — it needs the storage
|
||||
* bucket and differs per surface (the API route archives, best-effort).
|
||||
*/
|
||||
export async function createRotRutPayoutRequest(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
params: {
|
||||
type: DeductionType
|
||||
invoiceIds: string[]
|
||||
name?: string
|
||||
today?: string
|
||||
},
|
||||
): Promise<CreateRotRutRequestResult> {
|
||||
const today = params.today ?? new Date().toISOString().slice(0, 10)
|
||||
const name = (params.name ?? `${params.type.toUpperCase()} ${today}`).slice(0, 16)
|
||||
|
||||
const { data: invoices, error: invoicesError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('document_type', 'invoice')
|
||||
.in('id', params.invoiceIds)
|
||||
|
||||
if (invoicesError) {
|
||||
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
}
|
||||
const foundIds = new Set((invoices ?? []).map((i) => i.id))
|
||||
const missing = params.invoiceIds.filter((id) => !foundIds.has(id))
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, code: 'ROT_RUT_REQUEST_NOT_FOUND', missingInvoiceIds: missing }
|
||||
}
|
||||
|
||||
const file = buildRotRutFile({
|
||||
type: params.type,
|
||||
name,
|
||||
invoices: (invoices ?? []) as unknown as Invoice[],
|
||||
today,
|
||||
})
|
||||
|
||||
if (!file.xml) {
|
||||
return { ok: false, code: 'ROT_RUT_NO_ELIGIBLE_INVOICES', blockers: file.blockers }
|
||||
}
|
||||
if (file.blockers.length > 0) {
|
||||
return { ok: false, code: 'ROT_RUT_INVOICES_BLOCKED', blockers: file.blockers }
|
||||
}
|
||||
|
||||
const { data: payoutRequest, error: insertError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
deduction_type: params.type,
|
||||
name,
|
||||
status: 'generated',
|
||||
requested_total: file.requested_total,
|
||||
file_name: file.file_name,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !payoutRequest) {
|
||||
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
}
|
||||
|
||||
const itemRows = file.arenden.map((a) => ({
|
||||
request_id: payoutRequest.id,
|
||||
invoice_id: a.invoice_id,
|
||||
requested_amount: a.begart_belopp,
|
||||
}))
|
||||
const { error: itemsError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.insert(itemRows)
|
||||
|
||||
if (itemsError) {
|
||||
// Roll back the header row — without items the request is meaningless.
|
||||
await supabase.from('rot_rut_payout_requests').delete().eq('id', payoutRequest.id)
|
||||
const conflict =
|
||||
(itemsError as { code?: string }).code === '23505' ||
|
||||
itemsError.message?.includes('active rot/rut payout request')
|
||||
return { ok: false, code: conflict ? 'ROT_RUT_INVOICE_CONFLICT' : 'ROT_RUT_FILE_CREATE_FAILED' }
|
||||
}
|
||||
|
||||
return { ok: true, request: payoutRequest, file }
|
||||
}
|
||||
@@ -74,7 +74,8 @@ describe('dimension filter — statutory exclusion', () => {
|
||||
const reportRoutes = walk(join(ROOT, 'app/api/reports'))
|
||||
const importers = reportRoutes
|
||||
.filter((f) => readFileSync(f, 'utf8').includes('lib/reports/dimension-filter'))
|
||||
.map((f) => f.slice(ROOT.length + 1))
|
||||
// Normalize to POSIX separators so the allowlist matches on Windows too.
|
||||
.map((f) => f.slice(ROOT.length + 1).replace(/\\/g, '/'))
|
||||
.sort()
|
||||
|
||||
// Exactly the P&L-safe routes — nothing more (statutory leak), nothing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
||||
import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
|
||||
import { userHasPassword } from '@/lib/auth/has-password'
|
||||
|
||||
@@ -44,6 +45,34 @@ export async function updateSession(request: NextRequest) {
|
||||
// Get the pathname
|
||||
const pathname = request.nextUrl.pathname
|
||||
|
||||
// ── API routes ──────────────────────────────────────────────────────────
|
||||
// API routes authenticate themselves (requireAuth, API-key Bearer, cron
|
||||
// secret, webhook signatures). Middleware runs on them for ONE reason: to
|
||||
// close the MFA gap. Many legacy routes hand-roll supabase.auth.getUser()
|
||||
// instead of requireAuth(), so without this an authenticated-but-not-MFA-
|
||||
// verified (AAL1) cookie session could reach them on the hosted product.
|
||||
// Gate ONLY cookie sessions. Bearer-auth SURFACES (/api/v1, the MCP
|
||||
// endpoint) and the AAL1 escape-hatch / OAuth routes pass straight through
|
||||
// (see apiPathSkipsMfaGate) — header presence alone never skips the gate,
|
||||
// since the header is attacker-controlled and cookie-authenticated routes
|
||||
// ignore it. Pure Bearer callers (cron, webhooks) carry no cookie session,
|
||||
// so the `user` guard below already excludes them. Everything else about
|
||||
// /api auth stays the route's own responsibility.
|
||||
if (pathname.startsWith('/api')) {
|
||||
const skipMfaGate = apiPathSkipsMfaGate(
|
||||
pathname,
|
||||
request.headers.get('authorization') !== null,
|
||||
)
|
||||
if (!skipMfaGate && user && shouldEnforceMfa(user)) {
|
||||
const { data: aal } =
|
||||
await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
||||
return NextResponse.json({ error: 'MFA-verifiering krävs.' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// If the refresh token is stale/invalid, clear the session cookies
|
||||
// so the browser stops sending them on every request.
|
||||
// Skip on auth routes — the callback needs PKCE cookies intact.
|
||||
|
||||
@@ -1283,6 +1283,21 @@
|
||||
"credit_terms_label": "Payment terms (footnote)",
|
||||
"credit_terms_placeholder": "E.g. Payment is made to the specified bankgiro."
|
||||
},
|
||||
"settings_email_texts": {
|
||||
"heading": "Email texts",
|
||||
"description": "The texts in the email sent with the invoice. Edit freely — only your changes are saved. Does not apply to credit notes, proforma invoices or delivery notes.",
|
||||
"reset_label": "Reset to standard",
|
||||
"tab_sv": "Swedish",
|
||||
"tab_en": "English",
|
||||
"en_tab_hint": "Used for customers with English as their language.",
|
||||
"subject_label": "Subject line",
|
||||
"greeting_label": "Greeting",
|
||||
"body_label": "Body text",
|
||||
"signoff_label": "Sign-off",
|
||||
"placeholders_help": "Available placeholders (unknown ones are left as text):",
|
||||
"firstname_note": "The first-name placeholder uses the first word of the customer's name.",
|
||||
"toast_save_failed": "Could not save"
|
||||
},
|
||||
"settings_period_locking": {
|
||||
"heading": "Period locking",
|
||||
"locked_through_label": "Bookkeeping locked through",
|
||||
@@ -3360,6 +3375,7 @@
|
||||
"toast_attach_failed_description": "{count} file(s) could not be linked to the journal entry. Try again from the bookkeeping page.",
|
||||
"fill_balance_tooltip": "Double-click to fill the balancing amount",
|
||||
"fill_balance_hint": "Tip: double-click debit or credit to fill the remaining difference.",
|
||||
"keyboard_hint": "Enter jumps to the next field — once the entry balances, Enter opens the review.",
|
||||
"review_month_changed": "Note: different month than the previous voucher ({prev} → {current}).",
|
||||
"review_period_locked": "This period is closed or locked — posting may be rejected.",
|
||||
"add_dimensions": "Cost centre/Project",
|
||||
|
||||
@@ -1283,6 +1283,21 @@
|
||||
"credit_terms_label": "Betalningsvillkor (fotnot)",
|
||||
"credit_terms_placeholder": "T.ex. Betalning sker till angivet bankgiro."
|
||||
},
|
||||
"settings_email_texts": {
|
||||
"heading": "E-posttexter",
|
||||
"description": "Texterna i mejlet som skickas med fakturan. Ändra fritt — bara dina ändringar sparas. Gäller inte kreditfakturor, proforma eller följesedlar.",
|
||||
"reset_label": "Återställ till standard",
|
||||
"tab_sv": "Svenska",
|
||||
"tab_en": "Engelska",
|
||||
"en_tab_hint": "Används för kunder med engelska som språk.",
|
||||
"subject_label": "Ämnesrad",
|
||||
"greeting_label": "Hälsning",
|
||||
"body_label": "Brödtext",
|
||||
"signoff_label": "Avslutning",
|
||||
"placeholders_help": "Tillgängliga platshållare (okända lämnas kvar som text):",
|
||||
"firstname_note": "Platshållaren förnamn använder första ordet i kundens namn.",
|
||||
"toast_save_failed": "Kunde inte spara"
|
||||
},
|
||||
"settings_period_locking": {
|
||||
"heading": "Periodlåsning",
|
||||
"locked_through_label": "Bokföring låst t.o.m.",
|
||||
@@ -3360,6 +3375,7 @@
|
||||
"toast_attach_failed_description": "{count} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.",
|
||||
"fill_balance_tooltip": "Dubbelklicka för att fylla i balanserande belopp",
|
||||
"fill_balance_hint": "Tips: dubbelklicka på debet eller kredit för att fylla i differensen.",
|
||||
"keyboard_hint": "Enter hoppar till nästa fält — när verifikatet balanserar öppnar Enter granskningen.",
|
||||
"review_month_changed": "Obs: annan månad än föregående verifikat ({prev} → {current}).",
|
||||
"review_period_locked": "Perioden är stängd eller låst — bokföring kan nekas.",
|
||||
"add_dimensions": "Kostnadsställe/Projekt",
|
||||
|
||||
+5
-2
@@ -12,9 +12,12 @@ export const config = {
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - api (API routes - they handle their own auth)
|
||||
* - Static assets (images, scripts, manifest, icons, etc.)
|
||||
*
|
||||
* NOTE: `/api` is intentionally INCLUDED so middleware can enforce the MFA
|
||||
* (AAL2) gate on cookie-authenticated API calls (updateSession short-
|
||||
* circuits API routes after that check — see lib/supabase/middleware.ts).
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|api|\\.well-known|sw\\.js|sw-register\\.js|manifest\\.json|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
|
||||
'/((?!_next/static|_next/image|favicon.ico|\\.well-known|sw\\.js|sw-register\\.js|manifest\\.json|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
|
||||
],
|
||||
}
|
||||
|
||||
Generated
+150
-292
@@ -57,7 +57,7 @@
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3011,18 +3011,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||
"version": "3.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.9.tgz",
|
||||
"integrity": "sha512-PZm6O9JI/gUPtQV9r2eaMuLb4yWqV2vz+ot03ORHWTKO343LSpZi0TqeXLB2ZZGDXLCw2SbfgsQ0GxoxXMl79g==",
|
||||
"version": "3.5.12",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.12.tgz",
|
||||
"integrity": "sha512-YyzzxVgYJ8DELmmkhn0Yr0rUj0dTJFf9Jp628K3S0ysInBWxLVDOS8i3RP91cCp4DMK4WYb4cVMhWA9i4knSJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-skeleton-parser": "2.1.9"
|
||||
"@formatjs/icu-skeleton-parser": "2.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.9.tgz",
|
||||
"integrity": "sha512-rsxswgHMfU1zUgB2byc08fesf83wLGjFnzLCEtuf00mx2doiqc6pYrf67raI37XqdRcGUviQepk2UKGqpng74Q==",
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz",
|
||||
"integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formatjs/intl-localematcher": {
|
||||
@@ -5758,16 +5758,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.11.0"
|
||||
"domelementtype": "~2.3.0",
|
||||
"domhandler": "~5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"selderee": "~0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/abort-controller": {
|
||||
@@ -9060,13 +9063,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"version": "5.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.13.tgz",
|
||||
"integrity": "sha512-j40NeNlSAivqnKEjzZYsGP/bKWr2zwuyb8XOYsC3i0ZlfM5UnTYTa7aCRkE8rYQvVzE1dRjVYdvZ1Epi6x+h6w==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libmime": "5.4.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -9099,15 +9102,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -9481,9 +9475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"version": "4.12.4",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz",
|
||||
"integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
@@ -9673,19 +9667,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -9800,15 +9781,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -9880,18 +9852,6 @@
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -10175,13 +10135,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
@@ -11362,15 +11322,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz",
|
||||
@@ -11810,19 +11761,22 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz",
|
||||
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"selderee": "^0.11.0"
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
@@ -11836,9 +11790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
@@ -11850,8 +11804,20 @@
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
@@ -11919,9 +11885,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/icu-minify": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.12.0.tgz",
|
||||
"integrity": "sha512-zDmM05uav3t3+kxSfRrNlmyXOdj2b+uHA+p04CG32eJabtaHbugXujuL+YfRkwP9joAnf0Uh+RMGCKD5NLa5rQ==",
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.1.tgz",
|
||||
"integrity": "sha512-nFYW2im0WJ3RUVZwabd71J8QTZRtkK1xxZBY7klg7a6KS/os17LZSj9q1VhbRSnk3S8Mv2I7F1izr/aEJ6cUsw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -12023,15 +11989,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.6.tgz",
|
||||
"integrity": "sha512-afAN2yNN7zjB77G1ZC5L8GtLrEshyBvOQXz88flxCO/ocTIQist98gu0r/O6H/SSiQhQsOOtWPxmCEvtDABXXQ==",
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.9.tgz",
|
||||
"integrity": "sha512-cGzymZerpDhVXRKjKLgXKda9gI29TU2o88L7gwNMHp3WZVxA/0c5tX52udXbW9JklDApolvMXZG6Dhhdz5eirA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "3.1.5",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.9"
|
||||
"@formatjs/fast-memoize": "3.1.6",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.12"
|
||||
}
|
||||
},
|
||||
"node_modules/intl-messageformat/node_modules/@formatjs/fast-memoize": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz",
|
||||
"integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
@@ -12732,12 +12704,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
@@ -12761,29 +12733,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.0.tgz",
|
||||
"integrity": "sha512-MAWyU2qYtCsrZ6GClgukz2jx73NQrzA6ASo9qzThuTG+A7+H3lWQuBsjoy9lls+v6q/epAeBdEMJ3T0f4b+uRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
@@ -13080,9 +13040,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
@@ -13163,35 +13133,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz",
|
||||
"integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==",
|
||||
"version": "3.9.12",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.12.tgz",
|
||||
"integrity": "sha512-kbT4xtkKEddonhDTjjWWVFJlI5axm0S9QuIAHs1ue3IEw+fNd9o4ytPKaG7p1LOE2aKifrWLjx/omOGLHz/9RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"@zone-eu/mailsplit": "5.4.13",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"html-to-text": "10.0.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.8",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"libmime": "5.4.0",
|
||||
"linkify-it": "5.0.1",
|
||||
"nodemailer": "9.0.1",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -14240,9 +14198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.12.0.tgz",
|
||||
"integrity": "sha512-v8KpppWG0yLLlChJ3Of6uoPew9LeRDBAtY6vpJmF7YJmBZlHEzzoEL4w1g1dAU+VleEPNoXNm9hg1eEsKWV5hw==",
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.1.tgz",
|
||||
"integrity": "sha512-aS8KTA+nNhSNJJBlIhxgvU135WzoObwzFwav4wTDti/Gmhxqe0fs/Q343igo8Z7HGqPB/xgmoagwySZAlHmIfA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -14254,11 +14212,11 @@
|
||||
"@formatjs/intl-localematcher": "^0.8.1",
|
||||
"@parcel/watcher": "^2.4.1",
|
||||
"@swc/core": "^1.15.2",
|
||||
"icu-minify": "^4.12.0",
|
||||
"icu-minify": "^4.13.1",
|
||||
"negotiator": "^1.0.0",
|
||||
"next-intl-swc-plugin-extractor": "^4.12.0",
|
||||
"next-intl-swc-plugin-extractor": "^4.13.1",
|
||||
"po-parser": "^2.1.1",
|
||||
"use-intl": "^4.12.0"
|
||||
"use-intl": "^4.13.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||
@@ -14271,9 +14229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl-swc-plugin-extractor": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.12.0.tgz",
|
||||
"integrity": "sha512-jUxVEu1Nryjt4YgaDktSys7ioOgQfcNPF/SF2dbPNxbVb6U+P1INRgHeCVN+EC59H2rnTFIQwbddmOCrUWFr3g==",
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.1.tgz",
|
||||
"integrity": "sha512-RhlH2DR1ViEXzcX7G3tDXAvzNrBL2Ph54Hq/q/9oP9eXQV/okh3UQpA/lx2k9U5Ck83CZOSgH0eFTNi5U+zyXw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/core": {
|
||||
@@ -14379,9 +14337,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -14657,16 +14615,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.9.0"
|
||||
"leac": "^0.7.0",
|
||||
"peberminta": "^0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
@@ -14736,12 +14694,12 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
@@ -14885,6 +14843,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -15469,13 +15433,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.1.tgz",
|
||||
"integrity": "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.16.0.tgz",
|
||||
"integrity": "sha512-SaKISwHtxvAxneF84Njgnzg+zdngUu1vOT/paRU1De9QF+zXQR3GnwJHSh+mpJPjUhsGD4WxYi5CfKkXMkDqwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mailparser": "3.9.1",
|
||||
"svix": "1.84.1"
|
||||
"postal-mime": "2.7.4",
|
||||
"standardwebhooks": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -15489,59 +15453,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resend/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/resend/node_modules/mailparser": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz",
|
||||
"integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.0",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "7.0.11",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend/node_modules/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend/node_modules/svix": {
|
||||
"version": "1.84.1",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
|
||||
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -15763,15 +15674,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
"parseley": "~0.13.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
@@ -16051,18 +15962,6 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -16402,13 +16301,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.85.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz",
|
||||
"integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==",
|
||||
"version": "1.96.1",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.96.1.tgz",
|
||||
"integrity": "sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
"standardwebhooks": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
@@ -17022,9 +16920,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.12.0.tgz",
|
||||
"integrity": "sha512-r+qVb7UI1+kiOhjYsmsNUCY+jrnjVopwGeFlmMyQj4YInlwZzgMeMSv9n8MqnWWy77HL5BVM8K2WgX50SbtcpA==",
|
||||
"version": "4.13.1",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.1.tgz",
|
||||
"integrity": "sha512-UU5C3zAC7yVg3m7rq5C8VF5J5jhAfvS19Wi9bPNCB9xB7jQYBsUcrqfdxs4Mxl9XR3x6BDB5K++iAw7/rcm3gg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -17035,7 +16933,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/fast-memoize": "^3.1.0",
|
||||
"@schummar/icu-type-parser": "1.21.5",
|
||||
"icu-minify": "^4.12.0",
|
||||
"icu-minify": "^4.13.1",
|
||||
"intl-messageformat": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -17079,19 +16977,6 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
@@ -17500,24 +17385,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -17564,19 +17431,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Custom inbound email domains for the invoice-inbox extension.
|
||||
--
|
||||
-- Today every company receives supplier invoices on a generated address on
|
||||
-- the single shared Resend inbound domain (company_inboxes.local_part @
|
||||
-- RESEND_INBOUND_DOMAIN). This table lets a company verify its own domain
|
||||
-- (or subdomain) via Resend's domain API — once status = 'verified', the
|
||||
-- inbound webhook routes mail for ANY local part on that domain to the
|
||||
-- company (catch-all), with no forwarding step.
|
||||
--
|
||||
-- Design notes:
|
||||
-- * Separate table (not columns on company_inboxes): domain verification
|
||||
-- is a domain lifecycle, while company_inboxes models rotating addresses
|
||||
-- on the shared domain. Keeping them apart leaves rotate_company_inbox()
|
||||
-- and its unique indexes untouched.
|
||||
-- * No user_id column — mirrors company_inboxes. The row is company
|
||||
-- configuration that must outlive the user who created it; a cascade on
|
||||
-- user deletion would silently drop a working mail route.
|
||||
-- * Global unique on lower(domain): one company owns a domain, across all
|
||||
-- tenants. Rows are hard-deleted on removal, which frees the name.
|
||||
-- * One custom domain per company (unique on company_id) — v1 scope;
|
||||
-- relaxing later is a constraint drop, not a remodel.
|
||||
-- * Only rows with status = 'verified' ever route mail. 'pending' claims
|
||||
-- must never receive email — DNS verification is the ownership proof.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Table
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.company_inbound_domains (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
-- Lowercased, punycoded hostname (validated app-side before insert).
|
||||
domain text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'verified', 'failed')),
|
||||
-- Resend's domain id + the DNS records the user must publish (records[]
|
||||
-- from the Resend API response, rendered verbatim in the UI).
|
||||
resend_domain_id text,
|
||||
dns_records jsonb,
|
||||
verified_at timestamptz,
|
||||
last_checked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- A domain belongs to exactly one company, across all tenants.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inbound_domains_domain
|
||||
ON public.company_inbound_domains (lower(domain));
|
||||
|
||||
-- One custom domain per company (v1).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_company_inbound_domains_company
|
||||
ON public.company_inbound_domains (company_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. RLS — SELECT for members, writes for owner/admin only
|
||||
-- (mirrors the tightened company_inboxes policies from
|
||||
-- 20260420190000_inbox_hardening.sql)
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.company_inbound_domains ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Idempotent: staging gets this DDL applied manually ahead of the branch
|
||||
-- merge, so a later replay of this migration must not fail on existing
|
||||
-- policies/triggers.
|
||||
DROP POLICY IF EXISTS "company_inbound_domains_select" ON public.company_inbound_domains;
|
||||
CREATE POLICY "company_inbound_domains_select" ON public.company_inbound_domains
|
||||
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||
|
||||
DROP POLICY IF EXISTS "company_inbound_domains_insert" ON public.company_inbound_domains;
|
||||
CREATE POLICY "company_inbound_domains_insert" ON public.company_inbound_domains
|
||||
FOR INSERT WITH CHECK (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "company_inbound_domains_update" ON public.company_inbound_domains;
|
||||
CREATE POLICY "company_inbound_domains_update" ON public.company_inbound_domains
|
||||
FOR UPDATE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "company_inbound_domains_delete" ON public.company_inbound_domains;
|
||||
CREATE POLICY "company_inbound_domains_delete" ON public.company_inbound_domains
|
||||
FOR DELETE USING (
|
||||
company_id IN (
|
||||
SELECT cm.company_id FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Triggers
|
||||
-- =============================================================================
|
||||
|
||||
DROP TRIGGER IF EXISTS company_inbound_domains_updated_at ON public.company_inbound_domains;
|
||||
CREATE TRIGGER company_inbound_domains_updated_at
|
||||
BEFORE UPDATE ON public.company_inbound_domains
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- Domain claims change where a company's mail is routed — audit them.
|
||||
DROP TRIGGER IF EXISTS audit_company_inbound_domains ON public.company_inbound_domains;
|
||||
CREATE TRIGGER audit_company_inbound_domains
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.company_inbound_domains
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,761 @@
|
||||
-- Role-gated write authorization + tenant guards on voucher SECURITY DEFINER RPCs.
|
||||
--
|
||||
-- WHY THIS MIGRATION EXISTS
|
||||
-- -------------------------
|
||||
-- The staging/dev database has carried, for some time, an authorization
|
||||
-- refactor that was never captured as a migration and therefore never reached
|
||||
-- production:
|
||||
--
|
||||
-- * public.current_active_company_id() — the single active company for the
|
||||
-- caller (user_preferences.active_company_id, validated against a live,
|
||||
-- non-archived membership; falls back to the earliest membership).
|
||||
-- * public.current_user_can_write() — true iff the caller is a non-viewer
|
||||
-- member of that active company.
|
||||
-- * Every company-scoped WRITE policy AND-s in current_user_can_write() and
|
||||
-- scopes the company to current_active_company_id(), so a `viewer` (or any
|
||||
-- non-member) cannot INSERT/UPDATE/DELETE tenant data by calling PostgREST
|
||||
-- directly — the app-layer requireWritePermission() guard is no longer the
|
||||
-- only thing standing between a viewer and a write.
|
||||
--
|
||||
-- lib/supabase/middleware.ts and lib/auth/require-write.ts already document and
|
||||
-- rely on these functions; the application is written for this design. On
|
||||
-- production the write policies still gate on membership only
|
||||
-- (company_id IN (SELECT user_company_ids())), so a viewer CAN currently write
|
||||
-- via the API. This migration makes the repository the source of truth and,
|
||||
-- when deployed, brings production in line with staging.
|
||||
--
|
||||
-- It ALSO hardens three voucher SECURITY DEFINER RPCs that were EXECUTE-able by
|
||||
-- anon/authenticated with NO caller-membership check (a cross-tenant hole: any
|
||||
-- authenticated caller who knows a draft UUID could POST
|
||||
-- /rest/v1/rpc/commit_journal_entry with a foreign company_id and post that
|
||||
-- tenant's draft). The guard is the canonical one from
|
||||
-- 20260619130100_securitydefiner_write_rpc_tenant_guards.sql: it reads the
|
||||
-- request.jwt.claims role and only constrains anon/authenticated callers.
|
||||
-- service_role / backend callers (no JWT role — the engine's service-role
|
||||
-- commit path, the MCP/API-key path whose company scoping happens in TS, the
|
||||
-- pg-real harness, migrations) bypass BY DESIGN, so those flows are unaffected.
|
||||
--
|
||||
-- Everything below is idempotent (CREATE OR REPLACE / DROP POLICY IF EXISTS),
|
||||
-- so it is a no-op against staging (which already has the policies) and a
|
||||
-- corrective apply against production.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Authorization helper functions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.current_active_company_id()
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT COALESCE(
|
||||
(
|
||||
SELECT up.active_company_id
|
||||
FROM public.user_preferences up
|
||||
JOIN public.company_members cm
|
||||
ON cm.user_id = up.user_id AND cm.company_id = up.active_company_id
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE up.user_id = auth.uid()
|
||||
),
|
||||
(
|
||||
SELECT cm.company_id
|
||||
FROM public.company_members cm
|
||||
JOIN public.companies c
|
||||
ON c.id = cm.company_id AND c.archived_at IS NULL
|
||||
WHERE cm.user_id = auth.uid()
|
||||
ORDER BY cm.created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
);
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.current_user_can_write()
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_members cm
|
||||
WHERE cm.user_id = auth.uid()
|
||||
AND cm.company_id = public.current_active_company_id()
|
||||
AND cm.role <> 'viewer'
|
||||
);
|
||||
$function$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Role-gated write policies (39 tables)
|
||||
-- DDL generated verbatim from the live staging catalog (pg_policies) so the
|
||||
-- predicates match exactly what the app has been running against.
|
||||
-- =============================================================================
|
||||
|
||||
-- NOTE: the staging catalog also carried an ai_usage_tracking_insert policy,
|
||||
-- but public.ai_usage_tracking was dropped by
|
||||
-- 20260504120000_remove_ai_subsystem.sql and no longer exists in the canonical
|
||||
-- schema — the table on staging is drift. That policy is intentionally NOT
|
||||
-- recreated here; a from-scratch migration chain would fail on it.
|
||||
|
||||
-- automation_webhooks was renamed to public.webhooks by
|
||||
-- 20260515170000_webhooks_v2 — the staging catalog still carried the
|
||||
-- pre-rename table name (drift). Gate the canonical table, dropping both the
|
||||
-- staging-era policy names and the legacy schema-sync names defensively
|
||||
-- (policies follow a table rename but keep their original names).
|
||||
DROP POLICY IF EXISTS automation_webhooks_insert ON public.webhooks;
|
||||
DROP POLICY IF EXISTS "Members can insert company webhooks" ON public.webhooks;
|
||||
DROP POLICY IF EXISTS webhooks_insert ON public.webhooks;
|
||||
CREATE POLICY webhooks_insert ON public.webhooks FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS automation_webhooks_update ON public.webhooks;
|
||||
DROP POLICY IF EXISTS "Members can update company webhooks" ON public.webhooks;
|
||||
DROP POLICY IF EXISTS webhooks_update ON public.webhooks;
|
||||
CREATE POLICY webhooks_update ON public.webhooks FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS automation_webhooks_delete ON public.webhooks;
|
||||
DROP POLICY IF EXISTS "Members can delete company webhooks" ON public.webhooks;
|
||||
DROP POLICY IF EXISTS webhooks_delete ON public.webhooks;
|
||||
CREATE POLICY webhooks_delete ON public.webhooks FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_connections_insert ON public.bank_connections;
|
||||
CREATE POLICY bank_connections_insert ON public.bank_connections FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_connections_update ON public.bank_connections;
|
||||
CREATE POLICY bank_connections_update ON public.bank_connections FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_connections_delete ON public.bank_connections;
|
||||
CREATE POLICY bank_connections_delete ON public.bank_connections FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_file_imports_insert ON public.bank_file_imports;
|
||||
CREATE POLICY bank_file_imports_insert ON public.bank_file_imports FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_file_imports_update ON public.bank_file_imports;
|
||||
CREATE POLICY bank_file_imports_update ON public.bank_file_imports FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS bank_file_imports_delete ON public.bank_file_imports;
|
||||
CREATE POLICY bank_file_imports_delete ON public.bank_file_imports FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS btl_insert ON public.booking_template_library;
|
||||
CREATE POLICY btl_insert ON public.booking_template_library FOR INSERT TO public
|
||||
WITH CHECK (((NOT is_system) AND current_user_can_write() AND ((company_id = current_active_company_id()) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
|
||||
|
||||
DROP POLICY IF EXISTS btl_update ON public.booking_template_library;
|
||||
CREATE POLICY btl_update ON public.booking_template_library FOR UPDATE TO public
|
||||
USING (((NOT is_system) AND current_user_can_write() AND ((company_id IN ( SELECT user_company_ids() AS user_company_ids)) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
|
||||
|
||||
DROP POLICY IF EXISTS btl_delete ON public.booking_template_library;
|
||||
CREATE POLICY btl_delete ON public.booking_template_library FOR DELETE TO public
|
||||
USING (((NOT is_system) AND current_user_can_write() AND ((company_id IN ( SELECT user_company_ids() AS user_company_ids)) OR ((company_id IS NULL) AND (team_id IN ( SELECT user_team_ids() AS user_team_ids))))));
|
||||
|
||||
DROP POLICY IF EXISTS calendar_feeds_insert ON public.calendar_feeds;
|
||||
CREATE POLICY calendar_feeds_insert ON public.calendar_feeds FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS calendar_feeds_update ON public.calendar_feeds;
|
||||
CREATE POLICY calendar_feeds_update ON public.calendar_feeds FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS calendar_feeds_delete ON public.calendar_feeds;
|
||||
CREATE POLICY calendar_feeds_delete ON public.calendar_feeds FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS categorization_templates_insert ON public.categorization_templates;
|
||||
CREATE POLICY categorization_templates_insert ON public.categorization_templates FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS categorization_templates_update ON public.categorization_templates;
|
||||
CREATE POLICY categorization_templates_update ON public.categorization_templates FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS categorization_templates_delete ON public.categorization_templates;
|
||||
CREATE POLICY categorization_templates_delete ON public.categorization_templates FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chart_of_accounts_insert ON public.chart_of_accounts;
|
||||
CREATE POLICY chart_of_accounts_insert ON public.chart_of_accounts FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chart_of_accounts_update ON public.chart_of_accounts;
|
||||
CREATE POLICY chart_of_accounts_update ON public.chart_of_accounts FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chart_of_accounts_delete ON public.chart_of_accounts;
|
||||
CREATE POLICY chart_of_accounts_delete ON public.chart_of_accounts FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_messages_insert ON public.chat_messages;
|
||||
CREATE POLICY chat_messages_insert ON public.chat_messages FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_messages_update ON public.chat_messages;
|
||||
CREATE POLICY chat_messages_update ON public.chat_messages FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_messages_delete ON public.chat_messages;
|
||||
CREATE POLICY chat_messages_delete ON public.chat_messages FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_sessions_insert ON public.chat_sessions;
|
||||
CREATE POLICY chat_sessions_insert ON public.chat_sessions FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_sessions_update ON public.chat_sessions;
|
||||
CREATE POLICY chat_sessions_update ON public.chat_sessions FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS chat_sessions_delete ON public.chat_sessions;
|
||||
CREATE POLICY chat_sessions_delete ON public.chat_sessions FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS company_settings_delete ON public.company_settings;
|
||||
CREATE POLICY company_settings_delete ON public.company_settings FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS cost_centers_insert ON public.cost_centers;
|
||||
CREATE POLICY cost_centers_insert ON public.cost_centers FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS cost_centers_update ON public.cost_centers;
|
||||
CREATE POLICY cost_centers_update ON public.cost_centers FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS cost_centers_delete ON public.cost_centers;
|
||||
CREATE POLICY cost_centers_delete ON public.cost_centers FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS customers_insert ON public.customers;
|
||||
CREATE POLICY customers_insert ON public.customers FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS customers_update ON public.customers;
|
||||
CREATE POLICY customers_update ON public.customers FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS customers_delete ON public.customers;
|
||||
CREATE POLICY customers_delete ON public.customers FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS deadlines_insert ON public.deadlines;
|
||||
CREATE POLICY deadlines_insert ON public.deadlines FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS deadlines_update ON public.deadlines;
|
||||
CREATE POLICY deadlines_update ON public.deadlines FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS deadlines_delete ON public.deadlines;
|
||||
CREATE POLICY deadlines_delete ON public.deadlines FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS document_attachments_insert ON public.document_attachments;
|
||||
CREATE POLICY document_attachments_insert ON public.document_attachments FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS document_attachments_update ON public.document_attachments;
|
||||
CREATE POLICY document_attachments_update ON public.document_attachments FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS document_attachments_delete ON public.document_attachments;
|
||||
CREATE POLICY document_attachments_delete ON public.document_attachments FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS extension_data_insert ON public.extension_data;
|
||||
CREATE POLICY extension_data_insert ON public.extension_data FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS extension_data_update ON public.extension_data;
|
||||
CREATE POLICY extension_data_update ON public.extension_data FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS extension_data_delete ON public.extension_data;
|
||||
CREATE POLICY extension_data_delete ON public.extension_data FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS fiscal_periods_insert ON public.fiscal_periods;
|
||||
CREATE POLICY fiscal_periods_insert ON public.fiscal_periods FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS fiscal_periods_update ON public.fiscal_periods;
|
||||
CREATE POLICY fiscal_periods_update ON public.fiscal_periods FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS fiscal_periods_delete ON public.fiscal_periods;
|
||||
CREATE POLICY fiscal_periods_delete ON public.fiscal_periods FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_inbox_items_insert ON public.invoice_inbox_items;
|
||||
CREATE POLICY invoice_inbox_items_insert ON public.invoice_inbox_items FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_inbox_items_update ON public.invoice_inbox_items;
|
||||
CREATE POLICY invoice_inbox_items_update ON public.invoice_inbox_items FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_inbox_items_delete ON public.invoice_inbox_items;
|
||||
CREATE POLICY invoice_inbox_items_delete ON public.invoice_inbox_items FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_items_insert ON public.invoice_items;
|
||||
CREATE POLICY invoice_items_insert ON public.invoice_items FOR INSERT TO public
|
||||
WITH CHECK (((EXISTS ( SELECT 1
|
||||
FROM invoices i
|
||||
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_items_update ON public.invoice_items;
|
||||
CREATE POLICY invoice_items_update ON public.invoice_items FOR UPDATE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM invoices i
|
||||
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_items_delete ON public.invoice_items;
|
||||
CREATE POLICY invoice_items_delete ON public.invoice_items FOR DELETE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM invoices i
|
||||
WHERE ((i.id = invoice_items.invoice_id) AND (i.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_payments_insert ON public.invoice_payments;
|
||||
CREATE POLICY invoice_payments_insert ON public.invoice_payments FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_payments_update ON public.invoice_payments;
|
||||
CREATE POLICY invoice_payments_update ON public.invoice_payments FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_payments_delete ON public.invoice_payments;
|
||||
CREATE POLICY invoice_payments_delete ON public.invoice_payments FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_reminders_insert ON public.invoice_reminders;
|
||||
CREATE POLICY invoice_reminders_insert ON public.invoice_reminders FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_reminders_update ON public.invoice_reminders;
|
||||
CREATE POLICY invoice_reminders_update ON public.invoice_reminders FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoice_reminders_delete ON public.invoice_reminders;
|
||||
CREATE POLICY invoice_reminders_delete ON public.invoice_reminders FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoices_insert ON public.invoices;
|
||||
CREATE POLICY invoices_insert ON public.invoices FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoices_update ON public.invoices;
|
||||
CREATE POLICY invoices_update ON public.invoices FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS invoices_delete ON public.invoices;
|
||||
CREATE POLICY invoices_delete ON public.invoices FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entries_insert ON public.journal_entries;
|
||||
CREATE POLICY journal_entries_insert ON public.journal_entries FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entries_update ON public.journal_entries;
|
||||
CREATE POLICY journal_entries_update ON public.journal_entries FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entries_delete ON public.journal_entries;
|
||||
CREATE POLICY journal_entries_delete ON public.journal_entries FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entry_lines_insert ON public.journal_entry_lines;
|
||||
CREATE POLICY journal_entry_lines_insert ON public.journal_entry_lines FOR INSERT TO public
|
||||
WITH CHECK (((EXISTS ( SELECT 1
|
||||
FROM journal_entries je
|
||||
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entry_lines_update ON public.journal_entry_lines;
|
||||
CREATE POLICY journal_entry_lines_update ON public.journal_entry_lines FOR UPDATE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM journal_entries je
|
||||
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS journal_entry_lines_delete ON public.journal_entry_lines;
|
||||
CREATE POLICY journal_entry_lines_delete ON public.journal_entry_lines FOR DELETE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM journal_entries je
|
||||
WHERE ((je.id = journal_entry_lines.journal_entry_id) AND (je.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS mapping_rules_insert ON public.mapping_rules;
|
||||
CREATE POLICY mapping_rules_insert ON public.mapping_rules FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS mapping_rules_update ON public.mapping_rules;
|
||||
CREATE POLICY mapping_rules_update ON public.mapping_rules FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS mapping_rules_delete ON public.mapping_rules;
|
||||
CREATE POLICY mapping_rules_delete ON public.mapping_rules FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS payment_match_log_insert ON public.payment_match_log;
|
||||
CREATE POLICY payment_match_log_insert ON public.payment_match_log FOR INSERT TO public
|
||||
WITH CHECK ((((company_id = current_active_company_id()) OR (company_id IS NULL)) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS pending_operations_update ON public.pending_operations;
|
||||
CREATE POLICY pending_operations_update ON public.pending_operations FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS projects_insert ON public.projects;
|
||||
CREATE POLICY projects_insert ON public.projects FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS projects_update ON public.projects;
|
||||
CREATE POLICY projects_update ON public.projects FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS projects_delete ON public.projects;
|
||||
CREATE POLICY projects_delete ON public.projects FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS provider_consents_insert ON public.provider_consents;
|
||||
CREATE POLICY provider_consents_insert ON public.provider_consents FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS provider_consents_update ON public.provider_consents;
|
||||
CREATE POLICY provider_consents_update ON public.provider_consents FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS provider_consents_delete ON public.provider_consents;
|
||||
CREATE POLICY provider_consents_delete ON public.provider_consents FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipt_line_items_insert ON public.receipt_line_items;
|
||||
CREATE POLICY receipt_line_items_insert ON public.receipt_line_items FOR INSERT TO public
|
||||
WITH CHECK (((EXISTS ( SELECT 1
|
||||
FROM receipts r
|
||||
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipt_line_items_update ON public.receipt_line_items;
|
||||
CREATE POLICY receipt_line_items_update ON public.receipt_line_items FOR UPDATE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM receipts r
|
||||
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipt_line_items_delete ON public.receipt_line_items;
|
||||
CREATE POLICY receipt_line_items_delete ON public.receipt_line_items FOR DELETE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM receipts r
|
||||
WHERE ((r.id = receipt_line_items.receipt_id) AND (r.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipts_insert ON public.receipts;
|
||||
CREATE POLICY receipts_insert ON public.receipts FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipts_update ON public.receipts;
|
||||
CREATE POLICY receipts_update ON public.receipts FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS receipts_delete ON public.receipts;
|
||||
CREATE POLICY receipts_delete ON public.receipts FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_account_mappings_insert ON public.sie_account_mappings;
|
||||
CREATE POLICY sie_account_mappings_insert ON public.sie_account_mappings FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_account_mappings_update ON public.sie_account_mappings;
|
||||
CREATE POLICY sie_account_mappings_update ON public.sie_account_mappings FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_account_mappings_delete ON public.sie_account_mappings;
|
||||
CREATE POLICY sie_account_mappings_delete ON public.sie_account_mappings FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_imports_insert ON public.sie_imports;
|
||||
CREATE POLICY sie_imports_insert ON public.sie_imports FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_imports_update ON public.sie_imports;
|
||||
CREATE POLICY sie_imports_update ON public.sie_imports FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS sie_imports_delete ON public.sie_imports;
|
||||
CREATE POLICY sie_imports_delete ON public.sie_imports FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS skatteverket_tokens_insert ON public.skatteverket_tokens;
|
||||
CREATE POLICY skatteverket_tokens_insert ON public.skatteverket_tokens FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS skatteverket_tokens_update ON public.skatteverket_tokens;
|
||||
CREATE POLICY skatteverket_tokens_update ON public.skatteverket_tokens FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS skatteverket_tokens_delete ON public.skatteverket_tokens;
|
||||
CREATE POLICY skatteverket_tokens_delete ON public.skatteverket_tokens FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_items_insert ON public.supplier_invoice_items;
|
||||
CREATE POLICY supplier_invoice_items_insert ON public.supplier_invoice_items FOR INSERT TO public
|
||||
WITH CHECK (((EXISTS ( SELECT 1
|
||||
FROM supplier_invoices si
|
||||
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_items_update ON public.supplier_invoice_items;
|
||||
CREATE POLICY supplier_invoice_items_update ON public.supplier_invoice_items FOR UPDATE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM supplier_invoices si
|
||||
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_items_delete ON public.supplier_invoice_items;
|
||||
CREATE POLICY supplier_invoice_items_delete ON public.supplier_invoice_items FOR DELETE TO public
|
||||
USING (((EXISTS ( SELECT 1
|
||||
FROM supplier_invoices si
|
||||
WHERE ((si.id = supplier_invoice_items.supplier_invoice_id) AND (si.company_id = current_active_company_id())))) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_payments_insert ON public.supplier_invoice_payments;
|
||||
CREATE POLICY supplier_invoice_payments_insert ON public.supplier_invoice_payments FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_payments_update ON public.supplier_invoice_payments;
|
||||
CREATE POLICY supplier_invoice_payments_update ON public.supplier_invoice_payments FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoice_payments_delete ON public.supplier_invoice_payments;
|
||||
CREATE POLICY supplier_invoice_payments_delete ON public.supplier_invoice_payments FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoices_insert ON public.supplier_invoices;
|
||||
CREATE POLICY supplier_invoices_insert ON public.supplier_invoices FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoices_update ON public.supplier_invoices;
|
||||
CREATE POLICY supplier_invoices_update ON public.supplier_invoices FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS supplier_invoices_delete ON public.supplier_invoices;
|
||||
CREATE POLICY supplier_invoices_delete ON public.supplier_invoices FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS suppliers_insert ON public.suppliers;
|
||||
CREATE POLICY suppliers_insert ON public.suppliers FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS suppliers_update ON public.suppliers;
|
||||
CREATE POLICY suppliers_update ON public.suppliers FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS suppliers_delete ON public.suppliers;
|
||||
CREATE POLICY suppliers_delete ON public.suppliers FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS transactions_insert ON public.transactions;
|
||||
CREATE POLICY transactions_insert ON public.transactions FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS transactions_update ON public.transactions;
|
||||
CREATE POLICY transactions_update ON public.transactions FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS transactions_delete ON public.transactions;
|
||||
CREATE POLICY transactions_delete ON public.transactions FOR DELETE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS voucher_gap_explanations_insert ON public.voucher_gap_explanations;
|
||||
CREATE POLICY voucher_gap_explanations_insert ON public.voucher_gap_explanations FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write() AND (EXISTS ( SELECT 1
|
||||
FROM (team_members tm
|
||||
JOIN companies c ON ((c.team_id = tm.team_id)))
|
||||
WHERE ((c.id = voucher_gap_explanations.company_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['owner'::text, 'admin'::text])))))));
|
||||
|
||||
DROP POLICY IF EXISTS voucher_gap_explanations_update ON public.voucher_gap_explanations;
|
||||
CREATE POLICY voucher_gap_explanations_update ON public.voucher_gap_explanations FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write() AND (EXISTS ( SELECT 1
|
||||
FROM (team_members tm
|
||||
JOIN companies c ON ((c.team_id = tm.team_id)))
|
||||
WHERE ((c.id = voucher_gap_explanations.company_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['owner'::text, 'admin'::text])))))));
|
||||
|
||||
DROP POLICY IF EXISTS voucher_sequences_insert ON public.voucher_sequences;
|
||||
CREATE POLICY voucher_sequences_insert ON public.voucher_sequences FOR INSERT TO public
|
||||
WITH CHECK (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
DROP POLICY IF EXISTS voucher_sequences_update ON public.voucher_sequences;
|
||||
CREATE POLICY voucher_sequences_update ON public.voucher_sequences FOR UPDATE TO public
|
||||
USING (((company_id = current_active_company_id()) AND current_user_can_write()));
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Tenant guards on voucher SECURITY DEFINER RPCs
|
||||
-- Bodies copied verbatim from their latest definitions; only the v_jwt_role
|
||||
-- DECLARE + guard block, SET search_path = public, and the REVOKE/GRANT are
|
||||
-- added. anon/authenticated cross-tenant callers are refused (42501);
|
||||
-- service_role / backend (no JWT role) bypass BY DESIGN.
|
||||
-- =============================================================================
|
||||
|
||||
-- 3a. commit_journal_entry
|
||||
CREATE OR REPLACE FUNCTION public.commit_journal_entry(
|
||||
p_company_id uuid,
|
||||
p_entry_id uuid,
|
||||
p_commit_method text DEFAULT NULL::text,
|
||||
p_rubric_version text DEFAULT NULL::text,
|
||||
p_actor_type text DEFAULT NULL::text,
|
||||
p_actor_label text DEFAULT NULL::text
|
||||
)
|
||||
RETURNS TABLE(voucher_number integer)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_next integer;
|
||||
v_fiscal_period_id uuid;
|
||||
v_series text;
|
||||
v_entry_user_id uuid;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
BEGIN
|
||||
-- Tenant guard: anon/authenticated may only commit entries in their own
|
||||
-- companies; service_role / backend (no JWT role) bypasses BY DESIGN.
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.actor_type', coalesce(p_actor_type, ''), true);
|
||||
PERFORM set_config('gnubok.actor_label', coalesce(p_actor_label, ''), true);
|
||||
|
||||
SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A'), je.user_id
|
||||
INTO v_fiscal_period_id, v_series, v_entry_user_id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = p_entry_id
|
||||
AND je.company_id = p_company_id
|
||||
AND je.status = 'draft'
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES (p_company_id, COALESCE(auth.uid(), v_entry_user_id), v_fiscal_period_id, v_series, 1)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
|
||||
DO UPDATE SET
|
||||
last_number = public.voucher_sequences.last_number + 1,
|
||||
updated_at = now()
|
||||
RETURNING last_number INTO v_next;
|
||||
|
||||
UPDATE public.journal_entries
|
||||
SET voucher_number = v_next,
|
||||
status = 'posted',
|
||||
commit_method = p_commit_method,
|
||||
rubric_version = p_rubric_version,
|
||||
committed_actor_type = p_actor_type,
|
||||
committed_actor_label = p_actor_label
|
||||
WHERE id = p_entry_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
RETURN QUERY SELECT v_next;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.commit_journal_entry(uuid, uuid, text, text, text, text) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.commit_journal_entry(uuid, uuid, text, text, text, text) TO authenticated;
|
||||
|
||||
-- 3b. next_voucher_number
|
||||
CREATE OR REPLACE FUNCTION public.next_voucher_number(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_series text DEFAULT 'A'::text
|
||||
)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_next integer;
|
||||
v_user_id uuid;
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Preserved from 20260623130000: under a service-role client auth.uid() is
|
||||
-- NULL and the INSERT would fail user_id NOT NULL before ON CONFLICT
|
||||
-- arbitration — fall back to the company owner.
|
||||
v_user_id := auth.uid();
|
||||
IF v_user_id IS NULL THEN
|
||||
SELECT created_by INTO v_user_id
|
||||
FROM public.companies
|
||||
WHERE id = p_company_id;
|
||||
END IF;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'next_voucher_number: no attributable user for company %', p_company_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES (p_company_id, v_user_id, p_fiscal_period_id, p_series, 1)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
|
||||
DO UPDATE SET
|
||||
last_number = public.voucher_sequences.last_number + 1,
|
||||
updated_at = now()
|
||||
RETURNING last_number INTO v_next;
|
||||
|
||||
RETURN v_next;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.next_voucher_number(uuid, uuid, text) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.next_voucher_number(uuid, uuid, text) TO authenticated;
|
||||
|
||||
-- 3c. detect_voucher_gaps
|
||||
CREATE OR REPLACE FUNCTION public.detect_voucher_gaps(
|
||||
p_company_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_series text DEFAULT 'A'::text
|
||||
)
|
||||
RETURNS TABLE(gap_start integer, gap_end integer)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND p_company_id NOT IN (SELECT public.user_company_ids()) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH numbered AS (
|
||||
SELECT voucher_number,
|
||||
LEAD(voucher_number) OVER (ORDER BY voucher_number) AS next_number
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = p_fiscal_period_id
|
||||
AND voucher_series = p_series
|
||||
AND status != 'draft'
|
||||
ORDER BY voucher_number
|
||||
)
|
||||
SELECT
|
||||
voucher_number + 1 AS gap_start,
|
||||
next_number - 1 AS gap_end
|
||||
FROM numbered
|
||||
WHERE next_number IS NOT NULL
|
||||
AND next_number > voucher_number + 1;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) TO authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,269 @@
|
||||
-- Rot & rut: begäran om utbetalning (Skatteverkets husavdragstjänst)
|
||||
--
|
||||
-- Tracks generated payout-request files (HUS XML, schema V6 — see
|
||||
-- dev_docs/skatteverket/husavdrag/) so an invoice can never end up in two
|
||||
-- active begäran, and so the Skatteverket outcome (utbetalt/avslag) can be
|
||||
-- recorded and settled against BAS 1513.
|
||||
--
|
||||
-- Also adds invoice_items.brf_org_number: ROT i bostadsrätt is reported with
|
||||
-- bostadsrättsföreningens orgnr + lägenhetsnummer instead of
|
||||
-- fastighetsbeteckning (BegaranCOMPONENT.xsd: BrfOrgNr, max 12 chars).
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. invoice_items.brf_org_number
|
||||
-- =============================================================================
|
||||
ALTER TABLE public.invoice_items
|
||||
ADD COLUMN IF NOT EXISTS brf_org_number TEXT NULL;
|
||||
|
||||
-- Format (digits/dash, XSD BrfOrgNrTYPE) is validated at the API layer via
|
||||
-- Zod, same approach as the other ROT/RUT columns (20260526121700). The DB
|
||||
-- only guards the hard XSD length cap.
|
||||
ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_brf_org_number_check;
|
||||
ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_brf_org_number_check
|
||||
CHECK (brf_org_number IS NULL OR char_length(brf_org_number) <= 12);
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. rot_rut_payout_requests — one row per generated begäran-fil
|
||||
-- =============================================================================
|
||||
CREATE TABLE public.rot_rut_payout_requests (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
|
||||
deduction_type TEXT NOT NULL CHECK (deduction_type IN ('rot', 'rut')),
|
||||
|
||||
-- NamnPaBegaranTYPE: 1–16 chars, shown in Skatteverkets e-tjänst.
|
||||
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 16),
|
||||
|
||||
-- Lifecycle: generated → submitted → paid | partially_paid | rejected.
|
||||
-- cancelled is allowed from generated/submitted (file never uploaded, or
|
||||
-- withdrawn before beslut). Transitions are enforced at the API layer;
|
||||
-- the DB constrains the value set.
|
||||
status TEXT NOT NULL DEFAULT 'generated' CHECK (status IN (
|
||||
'generated', 'submitted', 'paid', 'partially_paid', 'rejected', 'cancelled'
|
||||
)),
|
||||
|
||||
-- Sum of item requested_amount (kr). Denormalized for list views.
|
||||
requested_total NUMERIC(12,2) NOT NULL CHECK (requested_total >= 0),
|
||||
-- Filled in when Skatteverkets beslut is recorded.
|
||||
decided_total NUMERIC(12,2) NULL CHECK (decided_total >= 0),
|
||||
|
||||
-- The archived XML file (räkenskapsinformation, 7-year retention via the
|
||||
-- document_attachments WORM chain).
|
||||
file_name TEXT NOT NULL,
|
||||
file_document_id uuid NULL REFERENCES public.document_attachments(id) ON DELETE SET NULL,
|
||||
|
||||
-- Settlement voucher (debit 1930 / credit 1513) once utbetalningen booked.
|
||||
settlement_journal_entry_id uuid NULL REFERENCES public.journal_entries(id) ON DELETE SET NULL,
|
||||
|
||||
submitted_at timestamptz NULL,
|
||||
decided_at timestamptz NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.rot_rut_payout_requests ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "view own-company rot_rut_payout_requests"
|
||||
ON public.rot_rut_payout_requests FOR SELECT
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "insert own-company rot_rut_payout_requests"
|
||||
ON public.rot_rut_payout_requests FOR INSERT
|
||||
WITH CHECK (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "update own-company rot_rut_payout_requests"
|
||||
ON public.rot_rut_payout_requests FOR UPDATE
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "delete own-company rot_rut_payout_requests"
|
||||
ON public.rot_rut_payout_requests FOR DELETE
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
|
||||
CREATE INDEX idx_rot_rut_payout_requests_company_id
|
||||
ON public.rot_rut_payout_requests (company_id);
|
||||
CREATE INDEX idx_rot_rut_payout_requests_company_status
|
||||
ON public.rot_rut_payout_requests (company_id, status);
|
||||
|
||||
CREATE TRIGGER set_updated_at_rot_rut_payout_requests
|
||||
BEFORE UPDATE ON public.rot_rut_payout_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_rot_rut_payout_requests
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. rot_rut_payout_request_items — one row per (request, invoice)
|
||||
-- =============================================================================
|
||||
CREATE TABLE public.rot_rut_payout_request_items (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
request_id uuid NOT NULL REFERENCES public.rot_rut_payout_requests(id) ON DELETE CASCADE,
|
||||
-- RESTRICT: an invoice referenced by a begäran is bokföringsunderlag and
|
||||
-- must not disappear from under the request.
|
||||
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT,
|
||||
|
||||
-- BegartBelopp for this invoice (kr, whole kronor in the file; stored with
|
||||
-- öre precision because it mirrors invoices.deduction_total).
|
||||
requested_amount NUMERIC(12,2) NOT NULL CHECK (requested_amount > 0),
|
||||
-- Godkänt belopp from Skatteverkets beslut (null until decided).
|
||||
decided_amount NUMERIC(12,2) NULL CHECK (decided_amount >= 0),
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (request_id, invoice_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.rot_rut_payout_request_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "view own-company rot_rut_payout_request_items"
|
||||
ON public.rot_rut_payout_request_items FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.rot_rut_payout_requests r
|
||||
WHERE r.id = rot_rut_payout_request_items.request_id
|
||||
AND r.company_id IN (SELECT user_company_ids())
|
||||
));
|
||||
CREATE POLICY "insert own-company rot_rut_payout_request_items"
|
||||
ON public.rot_rut_payout_request_items FOR INSERT
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM public.rot_rut_payout_requests r
|
||||
WHERE r.id = rot_rut_payout_request_items.request_id
|
||||
AND r.company_id IN (SELECT user_company_ids())
|
||||
));
|
||||
CREATE POLICY "update own-company rot_rut_payout_request_items"
|
||||
ON public.rot_rut_payout_request_items FOR UPDATE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.rot_rut_payout_requests r
|
||||
WHERE r.id = rot_rut_payout_request_items.request_id
|
||||
AND r.company_id IN (SELECT user_company_ids())
|
||||
));
|
||||
CREATE POLICY "delete own-company rot_rut_payout_request_items"
|
||||
ON public.rot_rut_payout_request_items FOR DELETE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.rot_rut_payout_requests r
|
||||
WHERE r.id = rot_rut_payout_request_items.request_id
|
||||
AND r.company_id IN (SELECT user_company_ids())
|
||||
));
|
||||
|
||||
CREATE INDEX idx_rot_rut_payout_request_items_request_id
|
||||
ON public.rot_rut_payout_request_items (request_id);
|
||||
CREATE INDEX idx_rot_rut_payout_request_items_invoice_id
|
||||
ON public.rot_rut_payout_request_items (invoice_id);
|
||||
|
||||
CREATE TRIGGER set_updated_at_rot_rut_payout_request_items
|
||||
BEFORE UPDATE ON public.rot_rut_payout_request_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_rot_rut_payout_request_items
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_request_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Integrity guard: one active begäran per invoice + same-company check
|
||||
--
|
||||
-- An invoice may appear in any number of cancelled/rejected requests (retry
|
||||
-- after avslag) but in at most ONE active (generated/submitted/paid/
|
||||
-- partially_paid) request — otherwise the same deduction could be requested
|
||||
-- twice. Cross-table invariants can't be expressed as a UNIQUE index across
|
||||
-- a JOIN, hence the trigger.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.enforce_single_active_rot_rut_request()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_request_company uuid;
|
||||
v_invoice_company uuid;
|
||||
BEGIN
|
||||
SELECT company_id INTO v_request_company
|
||||
FROM public.rot_rut_payout_requests
|
||||
WHERE id = NEW.request_id;
|
||||
|
||||
SELECT company_id INTO v_invoice_company
|
||||
FROM public.invoices
|
||||
WHERE id = NEW.invoice_id;
|
||||
|
||||
IF v_invoice_company IS NULL OR v_invoice_company != v_request_company THEN
|
||||
RAISE EXCEPTION 'Invoice % does not belong to the same company as payout request %',
|
||||
NEW.invoice_id, NEW.request_id;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.rot_rut_payout_request_items i
|
||||
JOIN public.rot_rut_payout_requests r ON r.id = i.request_id
|
||||
WHERE i.invoice_id = NEW.invoice_id
|
||||
AND i.id != NEW.id
|
||||
AND r.status NOT IN ('cancelled', 'rejected')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Invoice % is already included in an active rot/rut payout request',
|
||||
NEW.invoice_id
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_single_active_rot_rut_request
|
||||
BEFORE INSERT OR UPDATE OF invoice_id, request_id ON public.rot_rut_payout_request_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_single_active_rot_rut_request();
|
||||
|
||||
-- The item-level trigger can be bypassed by flipping a cancelled/rejected
|
||||
-- request back to an active status while its invoices have meanwhile been
|
||||
-- included in another active request. Guard the reactivation path too.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_rot_rut_request_reactivation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF OLD.status IN ('cancelled', 'rejected')
|
||||
AND NEW.status NOT IN ('cancelled', 'rejected')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.rot_rut_payout_request_items mine
|
||||
JOIN public.rot_rut_payout_request_items other
|
||||
ON other.invoice_id = mine.invoice_id AND other.request_id != mine.request_id
|
||||
JOIN public.rot_rut_payout_requests r ON r.id = other.request_id
|
||||
WHERE mine.request_id = NEW.id
|
||||
AND r.status NOT IN ('cancelled', 'rejected')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot reactivate payout request %: an invoice is already included in another active request',
|
||||
NEW.id
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER enforce_rot_rut_request_reactivation
|
||||
BEFORE UPDATE OF status ON public.rot_rut_payout_requests
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_rot_rut_request_reactivation();
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. journal_entries.source_type: add 'rot_rut_payout' for the settlement
|
||||
-- voucher (debit 1930 / credit 1513) booked when Skatteverket pays out.
|
||||
-- =============================================================================
|
||||
ALTER TABLE public.journal_entries
|
||||
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
|
||||
|
||||
ALTER TABLE public.journal_entries
|
||||
ADD CONSTRAINT journal_entries_source_type_check
|
||||
CHECK (source_type IN (
|
||||
'manual', 'bank_transaction', 'invoice_created',
|
||||
'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
|
||||
'opening_balance', 'year_end',
|
||||
'storno', 'correction', 'import', 'system',
|
||||
'inbox_item',
|
||||
'supplier_invoice_registered', 'supplier_invoice_paid',
|
||||
'supplier_invoice_cash_payment', 'supplier_credit_note',
|
||||
'currency_revaluation',
|
||||
'supplier_invoice_privately_paid',
|
||||
'reminder_fee',
|
||||
'accrual',
|
||||
'result_appropriation',
|
||||
'rot_rut_payout'
|
||||
));
|
||||
|
||||
-- Reload PostgREST schema cache
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Per-company editable invoice email texts (subject, greeting, body, sign-off)
|
||||
-- in sv + en. NULL column / missing keys / whitespace-only values fall back to
|
||||
-- the hardcoded defaults in lib/email/invoice-templates.ts. Overrides apply
|
||||
-- ONLY to standard invoices (document_type = 'invoice' or absent, and not a
|
||||
-- credit note) — enforced in the template lib, not here.
|
||||
-- Length limits are enforced by UpdateSettingsSchema (the only write path);
|
||||
-- mirrors the invoice_late_fee_text / invoice_credit_terms_text precedent.
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN IF NOT EXISTS invoice_email_texts JSONB NULL;
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
DROP CONSTRAINT IF EXISTS company_settings_invoice_email_texts_object;
|
||||
ALTER TABLE public.company_settings
|
||||
ADD CONSTRAINT company_settings_invoice_email_texts_object
|
||||
CHECK (invoice_email_texts IS NULL OR jsonb_typeof(invoice_email_texts) = 'object');
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.invoice_email_texts IS
|
||||
'Overrides for the standard-invoice email: { sv?: { subject?, greeting?, body?, signoff? }, en?: {...} }. Placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp} are substituted at send time by lib/email/invoice-templates.ts. NULL / missing / whitespace-only fields fall back to the hardcoded defaults. Ignored for credit notes, proforma and delivery notes.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -180,6 +180,22 @@ export interface Profile {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Editable invoice email texts (standard invoices only; sv + en).
|
||||
// Missing / whitespace-only fields fall back to the hardcoded defaults in
|
||||
// lib/email/invoice-templates.ts. Supports the fixed placeholder set
|
||||
// {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}.
|
||||
export interface InvoiceEmailTextOverrides {
|
||||
subject?: string
|
||||
greeting?: string
|
||||
body?: string
|
||||
signoff?: string
|
||||
}
|
||||
|
||||
export interface InvoiceEmailTexts {
|
||||
sv?: InvoiceEmailTextOverrides
|
||||
en?: InvoiceEmailTextOverrides
|
||||
}
|
||||
|
||||
// Company Settings
|
||||
export interface CompanySettings {
|
||||
id: string
|
||||
@@ -288,6 +304,9 @@ export interface CompanySettings {
|
||||
invoice_header_text: string | null
|
||||
invoice_footer_text: string | null
|
||||
|
||||
// Editable invoice email texts. null = all defaults.
|
||||
invoice_email_texts: InvoiceEmailTexts | null
|
||||
|
||||
// Automation
|
||||
send_invoice_reminders: boolean
|
||||
|
||||
@@ -965,6 +984,9 @@ export interface InvoiceItem {
|
||||
housing_designation?: string | null
|
||||
/** Lägenhetsnummer. Optional, used for ROT in flerbostadshus. */
|
||||
apartment_number?: string | null
|
||||
/** Bostadsrättsföreningens orgnr. ROT i bostadsrätt reports lägenhetsnummer
|
||||
* + BRF orgnr instead of fastighetsbeteckning (Begaran.xsd: BrfOrgNr). */
|
||||
brf_org_number?: string | null
|
||||
|
||||
// Per-item dimensions bag, merged over the invoice's default_dimensions on
|
||||
// the revenue line this item books to (dimensions PR7). jsonb DEFAULT '{}'.
|
||||
@@ -973,6 +995,52 @@ export interface InvoiceItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Rot/rut payout request (begäran om utbetalning, Skatteverkets husavdragstjänst).
|
||||
// One row per generated HUS XML file; items link the invoices whose 1513
|
||||
// receivable the file requests. See lib/invoices/rot-rut-file.ts.
|
||||
export type RotRutPayoutRequestStatus =
|
||||
| 'generated'
|
||||
| 'submitted'
|
||||
| 'paid'
|
||||
| 'partially_paid'
|
||||
| 'rejected'
|
||||
| 'cancelled'
|
||||
|
||||
export interface RotRutPayoutRequest {
|
||||
id: string
|
||||
company_id: string
|
||||
user_id: string
|
||||
deduction_type: 'rot' | 'rut'
|
||||
/** NamnPaBegaran in the file — 1-16 chars, shown in Skatteverkets e-tjänst. */
|
||||
name: string
|
||||
status: RotRutPayoutRequestStatus
|
||||
requested_total: number
|
||||
decided_total: number | null
|
||||
file_name: string
|
||||
file_document_id: string | null
|
||||
settlement_journal_entry_id: string | null
|
||||
submitted_at: string | null
|
||||
decided_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Relations (populated when fetched)
|
||||
items?: RotRutPayoutRequestItem[]
|
||||
}
|
||||
|
||||
export interface RotRutPayoutRequestItem {
|
||||
id: string
|
||||
request_id: string
|
||||
invoice_id: string
|
||||
requested_amount: number
|
||||
decided_amount: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Relations (populated when fetched)
|
||||
invoice?: Invoice
|
||||
}
|
||||
|
||||
// Recurring Invoice Schedule (template + monthly cadence)
|
||||
export type RecurringInvoiceScheduleStatus = 'active' | 'paused'
|
||||
|
||||
@@ -1287,6 +1355,7 @@ export type JournalEntrySourceType =
|
||||
| 'reminder_fee'
|
||||
| 'accrual'
|
||||
| 'result_appropriation'
|
||||
| 'rot_rut_payout'
|
||||
|
||||
// Journal entry status
|
||||
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled'
|
||||
@@ -2234,6 +2303,33 @@ export interface CompanyInbox {
|
||||
deprecated_at: string | null
|
||||
}
|
||||
|
||||
export type CompanyInboundDomainStatus = 'pending' | 'verified' | 'failed'
|
||||
|
||||
// A DNS record the user must publish to verify their custom inbound domain
|
||||
// (verbatim from the Resend domains API).
|
||||
export interface InboundDomainDnsRecord {
|
||||
record: string
|
||||
name: string
|
||||
value: string
|
||||
type: string
|
||||
ttl: string
|
||||
status: string
|
||||
priority?: number
|
||||
}
|
||||
|
||||
export interface CompanyInboundDomain {
|
||||
id: string
|
||||
company_id: string
|
||||
domain: string
|
||||
status: CompanyInboundDomainStatus
|
||||
resend_domain_id: string | null
|
||||
dns_records: InboundDomainDnsRecord[] | null
|
||||
verified_at: string | null
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface InvoiceInboxItem {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
Reference in New Issue
Block a user