From 237b77a366d6a55189fb4c2c2db2ae14dc2672d5 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:57:59 +0200 Subject: [PATCH] feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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
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 * 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) * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .gitignore | 14 - app/api/documents/route.ts | 17 +- app/api/rot-rut/__tests__/routes.test.ts | 496 ++++++++++++ app/api/rot-rut/eligible/route.ts | 31 + app/api/rot-rut/payout-file/route.ts | 93 +++ app/api/rot-rut/payout-requests/[id]/route.ts | 139 ++++ .../payout-requests/[id]/settle/route.ts | 162 ++++ app/api/rot-rut/payout-requests/route.ts | 41 + .../companies/[companyId]/documents/route.ts | 14 +- components/bookkeeping/AccountCombobox.tsx | 28 +- components/bookkeeping/JournalEntryForm.tsx | 116 ++- .../general/InboxCustomDomainDialog.tsx | 300 +++++++ .../general/InvoiceInboxWorkspace.tsx | 2 + components/invoices/InvoiceEditor.tsx | 19 +- .../settings/InvoiceEmailTextsSettings.tsx | 210 +++++ .../sections/InvoicingSettingsContent.tsx | 6 + .../__tests__/custom-domain-routes.test.ts | 256 ++++++ .../__tests__/custom-domains.test.ts | 558 +++++++++++++ .../__tests__/inbound-webhook.test.ts | 142 +++- .../__tests__/resend-inbound.test.ts | 23 +- extensions/general/invoice-inbox/index.ts | 238 +++++- .../invoice-inbox/lib/custom-domains.ts | 525 ++++++++++++ .../invoice-inbox/lib/resend-inbound.ts | 13 + .../__tests__/payload-size.bench.test.ts | 9 +- .../__tests__/test-key-write-block.test.ts | 120 +++ extensions/general/mcp-server/server.ts | 175 +++- lib/api/__tests__/schemas.test.ts | 97 +++ lib/api/schemas.ts | 76 ++ lib/auth/__tests__/api-mfa-gate.test.ts | 47 ++ lib/auth/api-keys.ts | 2 + lib/auth/api-mfa-gate.ts | 49 ++ lib/bookkeeping/rot-rut-entries.ts | 67 ++ .../__tests__/document-service.test.ts | 55 ++ lib/core/documents/document-service.ts | 26 +- lib/email/__tests__/invoice-templates.test.ts | 216 +++++ lib/email/__tests__/user-text.test.ts | 63 ++ lib/email/invoice-templates.ts | 103 ++- lib/email/user-text.ts | 41 + lib/errors/structured-errors.ts | 41 + lib/events/types.ts | 2 +- lib/invoices/__tests__/rot-rut-file.test.ts | 434 ++++++++++ lib/invoices/build-invoice-write.ts | 42 +- lib/invoices/rot-rut-file.ts | 441 ++++++++++ lib/invoices/rot-rut-rules.ts | 28 +- lib/invoices/rot-rut-service.ts | 206 +++++ .../dimension-statutory-guard.test.ts | 3 +- lib/supabase/middleware.ts | 29 + messages/en.json | 16 + messages/sv.json | 16 + middleware.ts | 7 +- package-lock.json | 442 ++++------ package.json | 2 +- ...20260701090000_company_inbound_domains.sql | 114 +++ ...0_rls_role_gate_and_voucher_rpc_guards.sql | 761 ++++++++++++++++++ ...20260703090000_rot_rut_payout_requests.sql | 269 +++++++ .../20260703091000_invoice_email_texts.sql | 21 + tests/helpers.ts | 1 + tests/pg/company-inbound-domains.pg.test.ts | 168 ++++ tests/pg/rls-role-gate.pg.test.ts | 113 +++ tests/pg/rot-rut-payout-requests.pg.test.ts | 248 ++++++ types/index.ts | 96 +++ 61 files changed, 7695 insertions(+), 394 deletions(-) create mode 100644 app/api/rot-rut/__tests__/routes.test.ts create mode 100644 app/api/rot-rut/eligible/route.ts create mode 100644 app/api/rot-rut/payout-file/route.ts create mode 100644 app/api/rot-rut/payout-requests/[id]/route.ts create mode 100644 app/api/rot-rut/payout-requests/[id]/settle/route.ts create mode 100644 app/api/rot-rut/payout-requests/route.ts create mode 100644 components/extensions/general/InboxCustomDomainDialog.tsx create mode 100644 components/settings/InvoiceEmailTextsSettings.tsx create mode 100644 extensions/general/invoice-inbox/__tests__/custom-domain-routes.test.ts create mode 100644 extensions/general/invoice-inbox/__tests__/custom-domains.test.ts create mode 100644 extensions/general/invoice-inbox/lib/custom-domains.ts create mode 100644 extensions/general/mcp-server/__tests__/test-key-write-block.test.ts create mode 100644 lib/auth/__tests__/api-mfa-gate.test.ts create mode 100644 lib/auth/api-mfa-gate.ts create mode 100644 lib/bookkeeping/rot-rut-entries.ts create mode 100644 lib/email/__tests__/user-text.test.ts create mode 100644 lib/email/user-text.ts create mode 100644 lib/invoices/__tests__/rot-rut-file.test.ts create mode 100644 lib/invoices/rot-rut-file.ts create mode 100644 lib/invoices/rot-rut-service.ts create mode 100644 supabase/migrations/20260701090000_company_inbound_domains.sql create mode 100644 supabase/migrations/20260702093000_rls_role_gate_and_voucher_rpc_guards.sql create mode 100644 supabase/migrations/20260703090000_rot_rut_payout_requests.sql create mode 100644 supabase/migrations/20260703091000_invoice_email_texts.sql create mode 100644 tests/pg/company-inbound-domains.pg.test.ts create mode 100644 tests/pg/rls-role-gate.pg.test.ts create mode 100644 tests/pg/rot-rut-payout-requests.pg.test.ts diff --git a/.gitignore b/.gitignore index b42005de..841fea8f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts index 57bc9087..c74136c7 100644 --- a/app/api/documents/route.ts +++ b/app/api/documents/route.ts @@ -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 }, diff --git a/app/api/rot-rut/__tests__/routes.test.ts b/app/api/rot-rut/__tests__/routes.test.ts new file mode 100644 index 00000000..f18deef1 --- /dev/null +++ b/app/api/rot-rut/__tests__/routes.test.ts @@ -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 { + 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 { + 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 = {}) { + 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('') + expect(body.data.xml).toContain(`${PNR}`) + 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) + }) +}) diff --git a/app/api/rot-rut/eligible/route.ts b/app/api/rot-rut/eligible/route.ts new file mode 100644 index 00000000..41244452 --- /dev/null +++ b/app/api/rot-rut/eligible/route.ts @@ -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 }, + }) +}) diff --git a/app/api/rot-rut/payout-file/route.ts b/app/api/rot-rut/payout-file/route.ts new file mode 100644 index 00000000..eda00abd --- /dev/null +++ b/app/api/rot-rut/payout-file/route.ts @@ -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 }, +) diff --git a/app/api/rot-rut/payout-requests/[id]/route.ts b/app/api/rot-rut/payout-requests/[id]/route.ts new file mode 100644 index 00000000..b17a3008 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/route.ts @@ -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 = { + 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 = { 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 }, +) diff --git a/app/api/rot-rut/payout-requests/[id]/settle/route.ts b/app/api/rot-rut/payout-requests/[id]/settle/route.ts new file mode 100644 index 00000000..e77ba462 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/settle/route.ts @@ -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 = { + 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 }, +) diff --git a/app/api/rot-rut/payout-requests/route.ts b/app/api/rot-rut/payout-requests/route.ts new file mode 100644 index 00000000..cb71c2c4 --- /dev/null +++ b/app/api/rot-rut/payout-requests/route.ts @@ -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 }) +}) diff --git a/app/api/v1/companies/[companyId]/documents/route.ts b/app/api/v1/companies/[companyId]/documents/route.ts index 4b3f2236..a284fd37 100644 --- a/app/api/v1/companies/[companyId]/documents/route.ts +++ b/app/api/v1/companies/[companyId]/documents/route.ts @@ -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' }, }) } }, diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index e26841f3..422ee7ac 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -49,6 +49,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const containerRef = useRef(null) const internalInputRef = useRef(null) const listRef = useRef(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) => { 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) } diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index c0cbb377..a2f376d8 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -165,11 +165,16 @@ export default function JournalEntryForm({ // user typed in the combobox so we can prefill the dialog. const [creatingAccountForLine, setCreatingAccountForLine] = useState(null) const [createAccountPrefill, setCreateAccountPrefill] = useState('') - // 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(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) => { + 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) => { + 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) => { + 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 = ( -
+
{ + if (e.key === 'Escape' && !isSubmitting) { + e.stopPropagation() + setShowReview(false) + } + }} + >
+
+
+ ) : ( +
+
+
+ {domain.domain} + + {STATUS_BADGE[domain.status].label} + +
+
+ + +
+
+ + {domain.status === 'verified' ? ( +
+ +
+

+ Klart — ge dina leverantörer{' '} + faktura@{domain.domain} +

+

+ Alla adresser på domänen fungerar; allt landar i dokumentinkorgen. + {domain.verified_at ? ` Verifierad ${formatDateLong(domain.verified_at)}.` : ''} +

+
+
+ ) : ( +
+

+ 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. +

+ {records.length > 0 ? ( +
+ + + + + + + + + + + {records.map((r, i) => ( + + + + + + + + ))} + +
TypNamnVärdePrio +
{r.type}{r.name}{r.value} + {r.priority ?? '—'} + + +
+
+ ) : ( +

+ Inga DNS-poster tillgängliga — klicka på Kontrollera igen. +

+ )} +
+ )} +
+ )} + + + ) +} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index df37ed38..ae33a894 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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' diff --git a/components/invoices/InvoiceEditor.tsx b/components/invoices/InvoiceEditor.tsx index 20c50ce7..3c544879 100644 --- a/components/invoices/InvoiceEditor.tsx +++ b/components/invoices/InvoiceEditor.tsx @@ -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, diff --git a/components/settings/InvoiceEmailTextsSettings.tsx b/components/settings/InvoiceEmailTextsSettings.tsx new file mode 100644 index 00000000..9e4a4359 --- /dev/null +++ b/components/settings/InvoiceEmailTextsSettings.tsx @@ -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) => 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> + +function buildDisplay(stored: InvoiceEmailTexts | null | undefined): DisplayTexts { + const result = {} as DisplayTexts + for (const lang of LANGS) { + result[lang] = {} as Record + 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 + 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(() => 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( + 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 ( +
+
+

+ {t('heading')} +

+

{t('description')}

+
+ + + + {t('tab_sv')} + {t('tab_en')} + + {LANGS.map((lang) => ( + + {lang === 'en' && ( +

{t('en_tab_hint')}

+ )} + {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 ( +
+
+ + {modified && canWrite && ( + + )} +
+ {multiline ? ( +