diff --git a/.compliance/Data_Classification_Handling.md b/.compliance/Data_Classification_Handling.md index 50f0e4a6..3846b590 100644 --- a/.compliance/Data_Classification_Handling.md +++ b/.compliance/Data_Classification_Handling.md @@ -1,5 +1,7 @@ # Data Classification and Handling +Classification: Confidential + ## Restricted data Swedish personal identity numbers are Restricted personal data. They are not an @@ -31,6 +33,40 @@ personal and business data. Exact payloads are retained server-side as delivery evidence until `invoice_deliveries.retention_expires_at`. Browser list responses contain masked recipient domains and operational metadata only. After the BFL retention date, the daily redaction control removes recipients, message content, -provider message IDs, filenames, and attachment checksums. Selective audit rows -must contain delivery IDs, tenant IDs, status transitions, actors, timestamps, -and document linkage only, never email payload content. +provider message IDs, filenames, and attachment checksums. BCC recipients are +never returned by the browser delivery-list endpoint, and direct table selection +of the exact payload is limited to the sending user. Exact company evidence is +included only in owner/admin server-side statutory archives. Delivery writes use +service-only functions so browser clients cannot forge or mutate the evidence. +Selective audit rows must contain delivery IDs, tenant IDs, status transitions, +actors, timestamps, and document linkage only, never email payload content. + +The statutory archive's `data/invoice_deliveries.json` is Confidential and may +contain full To, CC, and BCC addresses, reply-to, sender name, subject, plain and +HTML message bodies, provider identifiers, error details, attachment metadata +and checksum, actor and tenant identifiers, status, timestamps, retention data, +and the exact sent PDF. It is not a minimized delivery-list response. Only an +owner or admin may generate it, authorization is independently rechecked with +explicit user and company predicates before the service-role export starts, and +all archive queries remain explicitly scoped to that company. + +## Full statutory archive + +The complete ZIP is Confidential and can contain personal data beyond invoice +delivery history: customer and supplier names, personal or organization +identifiers, email addresses, phone numbers, postal addresses, bank accounts, +IBAN and BIC values, invoice references and free-text notes; employee identity, +employment, absence, benefit, payroll and declaration records; transaction +descriptions, counterparties, account references and notes; company contact and +tax-contact details; user and actor identifiers in accounting and audit records; +and the contents and metadata of uploaded documents. Encrypted source fields +remain encrypted in structured dumps, while rendered PDFs and source documents +may contain their readable business content. + +The export is a data-portability and statutory-retention operation, not a +routine UI disclosure. It is available only to an active-company owner or +admin, is served with a private response, and is never written to application +logs. The authenticated RLS authorization is repeated through a stateless +service-role client with explicit `user_id` and `company_id` predicates. Export +queries filter by that company directly or use parent IDs fetched under the +same filter. Recipients must store and transfer the ZIP as Confidential data. diff --git a/.compliance/authorization-policy.md b/.compliance/authorization-policy.md index 2ca4f22e..632886c8 100644 --- a/.compliance/authorization-policy.md +++ b/.compliance/authorization-policy.md @@ -2,7 +2,7 @@ Status: **Approved Documented Security Decision** Owner: Emil Mattsson (emil.mattsson@arcim.io) -Last reviewed: 2026-05-11 +Last reviewed: 2026-07-23 This document records authorization decisions for Accounted that go beyond the default "the resource creator is the only person who can act on it" model. @@ -54,9 +54,19 @@ of who originally drafted it. Invoice delivery list responses are data-minimized even for authorized company members. They expose masked recipient domains and operational status, -but not message bodies, subjects, reply-to addresses, provider message IDs, or -attachment checksums. Archived PDFs are served only when their document row -belongs to the request's active company. +but not BCC recipients, message bodies, subjects, reply-to addresses, provider +message IDs, attachment filenames, or attachment checksums. Archived PDFs are +served only when their document row belongs to the request's active company. +The underlying exact delivery payload is selectable only by the user who sent +the message. Other members receive the minimized list through the dedicated +database function, so direct PostgREST access cannot bypass route minimization. +The complete statutory archive is an owner/admin-only server operation and may +include exact company delivery evidence. Deferred booking uses a separate +`SECURITY DEFINER` function that verifies active-company membership and exposes +only the latest archived document ID. The archive route verifies owner/admin +twice: first through the authenticated RLS client and then through a stateless +service-role client with explicit `company_id` and `user_id` predicates. Every +archive query is scoped by that `company_id` or by parent IDs selected for it. ### Why this is intentional @@ -97,6 +107,69 @@ Although authorization is by `company_id`, the audit trail is by `user_id`: ## Specific decisions +### Invoice CC and BCC configuration: owner or admin only + +**Decision.** Changing fixed invoice recipients or adding an arbitrary CC or +BCC recipient to an individual invoice send requires the actor to have the +`owner` or `admin` role in `company_members`. The dashboard hides those change +controls for other roles. The settings route protects fixed-recipient changes; +the dashboard and v1 send routes protect per-send additions before rendering, +number allocation, or email delivery. The database also rejects direct member +changes to fixed recipient fields. Once an owner or admin approves a fixed +recipient, it applies to every send by a writable company member without a new +role check. A fixed recipient that matches the customer address is de-duplicated +with To precedence because it does not introduce a new external disclosure. The +legacy company-email or authenticated sender-email fallback is also fixed +routing: it cannot be supplied by the request, and the sender already has access +to the invoice being sent. + +**Why.** Both fixed and per-send recipients can disclose customer invoice data +to a new external address. This is a distinct disclosure decision and needs a +narrower authorization boundary than ordinary invoice sending. Explicit +recipients that collide with To, fixed CC, fixed BCC, or another per-send +recipient are rejected instead of silently changing recipient classification. + +**Compensating audit.** Successful invoice sends retain the exact immutable +recipient payload in `invoice_deliveries`, including the actor and company. +Routine delivery-list and send responses remain minimized and never expose BCC. + +**Service write boundary.** Delivery persistence uses a stateless service-role +client because authenticated PostgREST writes are intentionally revoked. The +service-only RPCs do not trust that client alone: they verify the supplied actor +is a writable member of the supplied company and bind every invoice and +delivery row to that company. Dashboard routes enter through `withRouteContext`, +v1 and MCP routes enter through `withApiV1` or the approved pending-operation +path, and recurring sends derive actor, company, invoice, and schedule from the +same company-scoped job before calling the RPC. + +**Cross-references.** +- OWASP ASVS V2.3: business logic integrity +- OWASP ASVS V8.2.1: operation-level authorization +- GDPR Articles 5(1)(c) and 25(2): minimization and privacy by default +- SOC 2 CC6.1: logical access + +### Invoice payment instructions: owner or admin only + +**Decision.** Changing the currency-keyed `invoice_payment_accounts` or any +legacy SEK mirror field requires the `owner` or `admin` role. The legacy fields +are `bank_name`, `clearing_number`, `account_number`, `bankgiro`, `plusgiro`, +`swish`, `iban`, and `bic`. The settings route enforces this before persistence, +matching the existing `company_settings` RLS policy. + +**Why.** Payment instructions determine where a customer sends company funds. +They need the same administrative boundary as other company financial settings. +All payable invoices, including SEK invoices, must resolve a usable account +before PDF rendering or invoice-number allocation. Credit notes, proformas, and +delivery notes remain exempt because they do not request payment. +Resends are not exempt: the current implementation renders a new PDF from +current company settings instead of reusing an earlier delivery snapshot, so it +must not send a payable document with blank or obsolete remittance details. + +**Cross-references.** +- OWASP ASVS V2.3: business logic integrity +- OWASP ASVS V8.2.1: operation-level authorization +- SOC 2 CC6.1: logical access + ### bank_connections: managed at company scope **Decision.** Any active `company_members` row for a company can manage diff --git a/.compliance/dpia-invoice-delivery-history.md b/.compliance/dpia-invoice-delivery-history.md index 80a5b2c1..f63768c5 100644 --- a/.compliance/dpia-invoice-delivery-history.md +++ b/.compliance/dpia-invoice-delivery-history.md @@ -1,5 +1,7 @@ # DPIA screening: invoice delivery history +Classification: Confidential + Date: 2026-07-22 Owner: Accounted controller Status: Screening completed @@ -19,14 +21,40 @@ the sent accounting document. It is not necessary in the routine browser list. The list therefore exposes only status, timestamps, masked recipient domains, provider name, error code, and an active-company-scoped link to the archived PDF. Subjects, bodies, full addresses, reply-to addresses, provider message IDs, -and checksums are excluded. +BCC recipients, filenames, and checksums are excluded. + +The owner/admin full statutory archive has a different legal and operational +purpose from the routine list, so it intentionally does not apply the list's +field minimization to `data/invoice_deliveries.json`. That export contains the +delivery and tenant identifiers, actor identifier, channel and status, full To, +CC, and BCC recipient arrays, reply-to and sender name, subject, plain-text and +HTML bodies, provider and provider message identifier, error code, archived +document identifier, attachment filename, content type and SHA-256 checksum, +delivery timestamps, retention and redaction timestamps, and creation time. The +ZIP may also contain the exact sent PDF and other company accounting records. +Access is therefore restricted to owner/admin and returned only as a private +server-generated export. ## Risks and controls - Cross-tenant disclosure: route context, explicit `company_id` filters, RLS, - and active-company document authorization. + active-company document authorization, and a second owner/admin membership + verification through the stateless service-role client before export. Every + service-role archive query uses the verified `company_id` directly or IDs + derived from rows scoped to that company. - Excess browser disclosure: allow-listed response fields, domain masking, and - `private, no-store` caching. + `private, no-store` caching. BCC recipients never leave the server-side + delivery evidence through the list endpoint. The exact table payload is + sender-only under RLS; other members use a masked summary function. Complete + statutory exports are owner/admin-only server operations. Their exact payload + exception is limited to the downloadable statutory archive purpose described + above and is not reused by the routine history endpoint. + The summary function is defined in migration `20260723003000` and the route + applies domain masking again before returning its allow-listed fields. +- Forged delivery evidence: authenticated PostgREST INSERT and UPDATE access is + removed. Server-only functions bind reservations and state transitions to a + verified writable company member. Payload-free crashed reservations may be + reclaimed by another sender only after 15 minutes. - Undocumented mutation: immutable status transitions plus a metadata-only audit trigger. Audit state excludes recipients and message content. - Excess retention: fiscal-period-derived `retention_expires_at` and daily PII diff --git a/.compliance/evidence/supabase-staging-2026-07-22.md b/.compliance/evidence/supabase-staging-2026-07-22.md new file mode 100644 index 00000000..9d5ad64c --- /dev/null +++ b/.compliance/evidence/supabase-staging-2026-07-22.md @@ -0,0 +1,16 @@ +# Supabase staging migration evidence: 2026-07-22 + +Classification: Confidential + +Environment: `erpbase` Supabase staging branch + +The isolated, history-hydrated CLI dry run listed exactly these migrations, +and remote migration history confirmed both versions after application: + +- `20260722190000_invoice_email_cc_bcc.sql` + - SHA-256: `3485F7E6D4E03ED9688BD99C3E2736BD6BED0C3C48E8BDA0D6EE813F1E49B99A` +- `20260722191000_invoice_payment_accounts_by_currency.sql` + - SHA-256: `B72FAFCCC29EA1B5FE4853F1053C1E6D46F8DEDFB67010A79882FE50D005C46A` + +This file records deployment evidence only. Architectural rationale remains in +`DECISIONS.md`. diff --git a/DECISIONS.md b/DECISIONS.md index 9b669093..2039f007 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -283,3 +283,30 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-22] Stuck-committing recovery sweep (#843) rejects rows without positive evidence instead of reverting to pending, and only three op types (categorize_transaction, link_transaction_journal_entry, match_transaction_invoice) can recover to committed: no generic side-effect -> pending_op linkage exists yet (that is #842's posted-ids work), so evidence is limited to types whose params identify a target row with an unambiguous posted state; reverting to pending risks re-executing side-effects that posted without a trace (duplicate entries/emails). [2026-07-22] MCP briefing recommended_tools (#1098) ships as a STATIC per-workflow loadout list, not state-gated: the briefing does not query workflow state (unbooked counts, open periods) today, so gating would add reads to the session-bootstrap hot path for marginal honesty; drift protection is a module-init assert against the tool registry + workflow-skill slugs, pinned by tests. [2026-07-22] failed_partial (#842) is a TERMINAL, immutable pending_operations status, never released back to pending: the executor already posted an irreversible voucher/credit note, so a retry would double-post and a status rewrite would violate BFL 7 kap.; recovery is a manual storno guided by result_data.posted_ids. Exception kept: AccountsNotInChartError in match_transaction_invoice still releases to pending because that executor is re-entrant past the storno. +[2026-07-22] Give statutory annual reports a dedicated final-closing exclusion while preserving the broad year_end exclusion used by operational tax, disposition, and cash-flow reports: the annual report must retain booked depreciation, appropriations, and tax; closed legacy periods without a linked final closing entry are backfilled only when the canonical entry is unambiguous and otherwise fail explicitly. +[2026-07-22] Present tax and domestic VAT settlement accounts by net economic sign in statutory annual reports without rewriting posted entries: a debit on 2650 is a current receivable, while a credit remains a current liability. +[2026-07-22] Store invoice payment instructions as a currency-keyed company setting and keep legacy bank fields as the SEK compatibility mirror: invoice instructions must work without a bank integration, exact currency matching prevents foreign invoices from inheriting an unsafe SEK account, and archived delivery PDFs remain the historical snapshot. +[2026-07-22] Extend the immutable invoice delivery record with BCC instead of adding a second invoice event log: one delivery record now remains the source of truth for channel, status, time, recipients, message, and exact archived PDF. +[2026-07-22] Keep exact invoice delivery payloads as immutable server-side evidence while returning only masked To and CC domains plus operational metadata in the routine list: BCC and message content are confidential, and archived PDFs provide active-company-scoped document evidence without widening the list response. +[2026-07-23] Resolve the invoice currency's payment account inside the shared invoice email template: every dashboard, API, recurring, and queued send path must render the same payment instructions as its PDF, and central resolution prevents a foreign-currency email from leaking the legacy SEK account when a caller passes raw company settings. +[2026-07-23] Add a new migration before the currency-account backfill instead of editing the failed migration: immutable migration history is preserved, clean preview branches gain the legacy SEK columns before backfill, and environments that already have the columns remain safe through IF NOT EXISTS. +[2026-07-23] Restrict fixed and per-send invoice CC and BCC changes to owner and admin roles: an external copy is a separate disclosure decision, so both the API and database reject lower-role configuration changes before persistence, rendering, or invoice-number allocation. +[2026-07-23] Do not rewrite the already-replayed invoice recipient and payment-account migrations to add NOT VALID: immutable migration history takes precedence, and a later migration cannot remove the original deployment-time validation scan. +[2026-07-23] Exact invoice delivery payload is sender-only under RLS: all other active-company members use a SECURITY DEFINER summary that masks To and CC and omits BCC and message content. +[2026-07-23] Route invoice delivery writes through service-only RPCs and allow another sender to reclaim only a payload-free reservation older than 15 minutes: browser PostgREST clients cannot forge evidence, while a crashed render cannot permanently block vacation cover. +[2026-07-23] Keep complete invoice delivery evidence in the statutory archive through an owner/admin-only server client, while deferred booking receives only the latest sent document ID: company-wide accounting workflows remain complete without reopening exact browser payload access. +[2026-07-23] Treat fixed invoice CC and BCC addresses as owner/admin-approved company routing that applies to every authorized sender, while per-send additions require owner/admin on each request: configured routing supports vacation cover without letting members introduce a new recipient. +[2026-07-23] Require a usable payment account for every payable invoice currency, including SEK, before rendering or number allocation, and restrict payment-instruction changes to owner/admin: a numbered invoice must not be issued with blank or member-controlled remittance details. +[2026-07-23] Identify statutory current-year result rows with stable semantic keys while retaining label fallback for older snapshots: K2 and K3 presentation wording cannot bypass the annual-report result-integrity gate. +[2026-07-23] Keep exact invoice delivery payload in the owner/admin statutory archive instead of applying routine-list masking, and verify membership again with explicit user and company predicates before using the stateless service role: the archive preserves BFL evidence while the second guard limits cross-tenant impact from an RLS regression. +[2026-07-23] Preserve the legacy sender-email CC fallback and load the authenticated email in the send dialog preview: the user sees the same fixed recipient that the dashboard send route will resolve. +[2026-07-23] Require the current currency payment account on resends as well as first sends: resends render a new PDF from current settings, so allowing a missing account would distribute newly generated payment instructions that are blank or invalid. +[2026-07-23] Return only To and CC counts from the normal invoice-send response: fixed BCC routing applies to authorized senders but its existence and cardinality remain confidential outside the exact delivery archive. +[2026-07-23] Treat an unexpected delivery ID from a terminal delivery RPC exactly like an RPC error: the email outcome is already irreversible, so callers receive a reconciliation warning and failed-send cleanup never deletes an archive unless the expected row was actually finalized. +[2026-07-23] Cap the final de-duplicated To, CC, and BCC set at 20 before any delivery reservation, render, or number allocation: separate per-field limits could exceed the provider-safe total when fixed and per-send recipients were combined. +[2026-07-23] Mark invoice delivery, PDF, and statutory archive responses private and non-cacheable on success and failure, and omit BCC from both dashboard and v1 send responses: operational identifiers and blind recipients belong only in the restricted exact archive. +[2026-07-23] Block payment-account saves after server values change while local edits are dirty until the user explicitly reloads: silently preserving and later saving stale form state could overwrite another administrator's update. +[2026-07-23] Reconcile annual-report rounding residuals independently for each balance-sheet side and fail closed when a side cannot reach its own rounded exact total: cross-side netting could hide an incorrect reported fact behind a balanced grand total. +[2026-07-23] Validate preview-PDF payment settings before fetching customer data using the requested currency and document type: this preserves the same exemption semantics while minimizing personal-data processing for requests that cannot render. +[2026-07-23] Retain an exact pending delivery snapshot when the provider succeeds but the terminal evidence RPC cannot be confirmed, and keep it outside the preparing-only reservation lock: inventing a sent state would be unsafe, while immutable payload, PDF, operator warnings, and later explicit resend availability preserve evidence and recovery. +[2026-07-23] Keep the invoice-delivery DPIA as a documented screening rather than fabricating a full Article 35 assessment or DPO sign-off: the screened processing does not meet the high-risk threshold, and the implemented controls minimize routine access while preserving statutory evidence. diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts index 19c9633e..88fe4a49 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/__tests__/route.test.ts @@ -22,12 +22,14 @@ vi.mock('@/lib/bokslut/arsredovisning/model', () => ({ })) vi.mock('@/lib/bokslut/arsredovisning/version-service', () => ({ createAnnualReportVersion: vi.fn(), + hasStatementIntegrityErrors: vi.fn(), listAnnualReportVersions: vi.fn(), })) import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model' import { createAnnualReportVersion, + hasStatementIntegrityErrors, listAnnualReportVersions, } from '@/lib/bokslut/arsredovisning/version-service' import { GET, POST } from '../route' @@ -57,6 +59,7 @@ function setup() { vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({ validation: { ok: true }, } as never) + vi.mocked(hasStatementIntegrityErrors).mockReturnValue(false) return mock } @@ -134,6 +137,40 @@ describe('annual report versions route', () => { ) }) + it.each(['snapshot', 'finalize'] as const)( + 'rejects an inconsistent report before creating a %s version', + async (action) => { + const { enqueue } = setup() + enqueue({ data: { id: 'period-1' } }) + vi.mocked(hasStatementIntegrityErrors).mockReturnValue(true) + vi.mocked(buildCanonicalAnnualReport).mockResolvedValue({ + validation: { + ok: false, + issues: [{ code: 'AR-RESULT-MISMATCH', severity: 'error' }], + }, + } as never) + + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en: string } + }>( + await POST( + createMockRequest('/x', { method: 'POST', body: { action } }), + params, + ), + ) + + expect(status).toBe(409) + expect(body.error).toEqual( + expect.objectContaining({ + code: 'ARSREDOVISNING_INCOMPLETE', + message: expect.any(String), + message_en: expect.any(String), + }), + ) + expect(createAnnualReportVersion).not.toHaveBeenCalled() + }, + ) + it('accepts a VD as the fastställelseintyg signer', async () => { const { enqueue } = setup() enqueue({ data: { id: 'period-1' } }) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts index cb688b07..f5f650d0 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/versions/route.ts @@ -8,6 +8,7 @@ import { createServiceClient } from '@/lib/supabase/server' import { buildCanonicalAnnualReport } from '@/lib/bokslut/arsredovisning/model' import { createAnnualReportVersion, + hasStatementIntegrityErrors, listAnnualReportVersions, } from '@/lib/bokslut/arsredovisning/version-service' @@ -83,16 +84,17 @@ export const POST = withRouteContext( } : undefined, }) + if (hasStatementIntegrityErrors(model)) { + return errorResponseFromCode('ARSREDOVISNING_INCOMPLETE', log, { + requestId, + details: model.validation, + }) + } if (validation.data.action === 'finalize' && !model.validation.ok) { - return NextResponse.json( - { - error: { - code: 'ARSREDOVISNING_INCOMPLETE', - details: model.validation, - }, - }, - { status: 409 }, - ) + return errorResponseFromCode('ARSREDOVISNING_INCOMPLETE', log, { + requestId, + details: model.validation, + }) } const data = await createAnnualReportVersion( validation.data.action === 'finalize' ? createServiceClient() : supabase, diff --git a/app/api/invoices/[id]/book/__tests__/route.test.ts b/app/api/invoices/[id]/book/__tests__/route.test.ts index 0c0b150d..524ac0e1 100644 --- a/app/api/invoices/[id]/book/__tests__/route.test.ts +++ b/app/api/invoices/[id]/book/__tests__/route.test.ts @@ -202,7 +202,7 @@ describe('POST /api/invoices/[id]/book', () => { enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null }) - enqueue({ data: { document_attachment_id: 'document-1' }, error: null }) + enqueue({ data: 'document-1', error: null }) const { status } = await parseJsonResponse(await bookRequest()) @@ -213,5 +213,9 @@ describe('POST /api/invoices/[id]/book', () => { 'document-1', 'je-1', ) + expect(mockSupabase.rpc).toHaveBeenCalledWith( + 'latest_sent_invoice_delivery_document', + { p_company_id: 'company-1', p_invoice_id: 'inv-1' }, + ) }) }) diff --git a/app/api/invoices/[id]/book/route.ts b/app/api/invoices/[id]/book/route.ts index a92ae6db..e8bece2d 100644 --- a/app/api/invoices/[id]/book/route.ts +++ b/app/api/invoices/[id]/book/route.ts @@ -126,16 +126,10 @@ export const POST = withRouteContext( // The send flow archived the exact delivered PDF before this deferred // journal entry existed. Attach the newest successful delivery snapshot now. - const { data: deliveryDocument, error: deliveryDocumentError } = await supabase - .from('invoice_deliveries') - .select('document_attachment_id') - .eq('invoice_id', id) - .eq('company_id', companyId) - .eq('status', 'sent') - .not('document_attachment_id', 'is', null) - .order('sent_at', { ascending: false }) - .limit(1) - .maybeSingle() + const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc( + 'latest_sent_invoice_delivery_document', + { p_company_id: companyId, p_invoice_id: id }, + ) if (deliveryDocumentError) { log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, { @@ -145,18 +139,18 @@ export const POST = withRouteContext( code: 'PDF_LINK_FAILED', message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.', }) - } else if (deliveryDocument?.document_attachment_id) { + } else if (typeof deliveryDocumentId === 'string') { try { await linkToJournalEntry( supabase, companyId!, - deliveryDocument.document_attachment_id, + deliveryDocumentId, journalEntry.id, ) } catch (err) { log.error('failed to link delivered invoice PDF on deferred booking', err as Error, { invoiceId: id, - documentId: deliveryDocument.document_attachment_id, + documentId: deliveryDocumentId, }) warnings.push({ code: 'PDF_LINK_FAILED', diff --git a/app/api/invoices/[id]/deliveries/__tests__/route.test.ts b/app/api/invoices/[id]/deliveries/__tests__/route.test.ts index a3d87599..478e8749 100644 --- a/app/api/invoices/[id]/deliveries/__tests__/route.test.ts +++ b/app/api/invoices/[id]/deliveries/__tests__/route.test.ts @@ -65,13 +65,14 @@ describe('GET /api/invoices/[id]/deliveries', () => { expect(response.status).toBe(404) }) - it('returns minimized delivery metadata with masked recipient domains', async () => { + it('returns minimized delivery evidence for the active company', async () => { const delivery = { id: 'delivery-1', channel: 'email', status: 'sent', to_addresses: ['customer@example.com'], - cc_addresses: [], + cc_addresses: ['accounts@example.com'], + bcc_addresses: ['archive@example.com'], reply_to: 'sender@example.com', from_name: 'Example AB', subject: 'Faktura F-1001', @@ -102,7 +103,7 @@ describe('GET /api/invoices/[id]/deliveries', () => { channel: 'email', status: 'sent', to_addresses: ['***@example.com'], - cc_addresses: [], + cc_addresses: ['***@example.com'], provider: 'resend', error_code: null, document_attachment_id: 'document-1', @@ -110,15 +111,20 @@ describe('GET /api/invoices/[id]/deliveries', () => { failed_at: null, created_at: '2026-07-22T10:29:59.000Z', }]) + expect(body.data[0]).not.toHaveProperty('bcc_addresses') + expect(body.data[0]).not.toHaveProperty('reply_to') + expect(body.data[0]).not.toHaveProperty('from_name') + expect(body.data[0]).not.toHaveProperty('subject') expect(body.data[0]).not.toHaveProperty('body_text') expect(body.data[0]).not.toHaveProperty('body_html') - expect(body.data[0]).not.toHaveProperty('subject') - expect(body.data[0]).not.toHaveProperty('reply_to') expect(body.data[0]).not.toHaveProperty('provider_message_id') expect(body.data[0]).not.toHaveProperty('attachment_filename') expect(body.data[0]).not.toHaveProperty('attachment_content_type') expect(body.data[0]).not.toHaveProperty('attachment_sha256') expect(response.headers.get('Cache-Control')).toBe('private, no-store') - expect(mockSupabase.from).toHaveBeenCalledWith('invoice_deliveries') + expect(mockSupabase.rpc).toHaveBeenCalledWith('list_invoice_delivery_summaries', { + p_company_id: 'company-1', + p_invoice_id: INVOICE_ID, + }) }) }) diff --git a/app/api/invoices/[id]/deliveries/route.ts b/app/api/invoices/[id]/deliveries/route.ts index 53fbe7b4..c8b7b6dc 100644 --- a/app/api/invoices/[id]/deliveries/route.ts +++ b/app/api/invoices/[id]/deliveries/route.ts @@ -2,48 +2,38 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import type { InvoiceDelivery } from '@/types' +import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types' -type DeliveryListRow = Pick< - InvoiceDelivery, - | 'id' - | 'channel' - | 'status' - | 'to_addresses' - | 'cc_addresses' - | 'provider' - | 'error_code' - | 'document_attachment_id' - | 'sent_at' - | 'failed_at' - | 'created_at' -> +interface InvoiceDeliverySummaryRow { + id: string + channel: InvoiceDeliveryChannel + status: InvoiceDeliveryStatus + to_addresses: string[] + cc_addresses: string[] + provider: string | null + error_code: string | null + document_attachment_id: string | null + sent_at: string | null + failed_at: string | null + created_at: string +} -const DELIVERY_COLUMNS = [ - 'id', - 'channel', - 'status', - 'to_addresses', - 'cc_addresses', - 'provider', - 'error_code', - 'document_attachment_id', - 'sent_at', - 'failed_at', - 'created_at', -].join(', ') +type MaskedRecipientAddress = string & { readonly __maskedRecipientAddress: true } -function maskRecipientDomain(address: string): string { - const separator = address.lastIndexOf('@') - if (separator <= 0 || separator === address.length - 1) return '***' - return `***@${address.slice(separator + 1)}` +interface MaskedInvoiceDeliverySummaryRow + extends Omit { + to_addresses: MaskedRecipientAddress[] + cc_addresses: MaskedRecipientAddress[] } /** * GET /api/invoices/[id]/deliveries * * Returns minimized delivery metadata for an invoice. Exact message content, - * provider identifiers, checksums, and full recipient addresses stay server-side. + * BCC recipients, provider identifiers, checksums, and full recipient + * addresses stay server-side. The database allow-list and masking boundary is + * defined by list_invoice_delivery_summaries in migration 20260723003000; this + * route masks returned addresses again as defense in depth. */ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 'invoice.deliveries.list', @@ -67,20 +57,19 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }) } - const { data: deliveries, error } = await supabase - .from('invoice_deliveries') - .select(DELIVERY_COLUMNS) - .eq('invoice_id', id) - .eq('company_id', companyId) - .neq('status', 'preparing') - .order('created_at', { ascending: false }) + const { data: deliveries, error } = await supabase.rpc( + 'list_invoice_delivery_summaries', + { p_company_id: companyId, p_invoice_id: id }, + ) if (error) { log.error('failed to list invoice deliveries', error, { invoiceId: id }) throw error } - const minimized = ((deliveries || []) as unknown as DeliveryListRow[]).map((delivery) => ({ + const minimized: MaskedInvoiceDeliverySummaryRow[] = ( + (deliveries || []) as unknown as InvoiceDeliverySummaryRow[] + ).map((delivery) => ({ id: delivery.id, channel: delivery.channel, status: delivery.status, @@ -100,3 +89,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( ) }, ) + +function maskRecipientDomain(address: string): MaskedRecipientAddress { + const separator = address.lastIndexOf('@') + if (separator <= 0 || separator === address.length - 1) { + return '***' as MaskedRecipientAddress + } + return `***@${address.slice(separator + 1)}` as MaskedRecipientAddress +} diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index 850ef120..9aaee87e 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -29,6 +29,11 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) +const mockEnsureInvoiceNumber = vi.fn() +vi.mock('@/lib/invoices/ensure-invoice-number', () => ({ + ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args), +})) + const mockRenderToBuffer = vi.fn() vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), @@ -86,6 +91,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { const company = makeCompanySettings({ accounting_method: 'accrual', entity_type: 'enskild_firma', + bankgiro: '123-4567', }) const invoice = makeInvoice({ id: 'inv-1', @@ -162,6 +168,43 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { expect(status).toBe(400) }) + it.each(['SEK', 'EUR'] as const)( + 'rejects a %s invoice without a payment account before number allocation', + async (currency) => { + enqueue({ + data: makeInvoice({ + ...invoice, + invoice_number: null, + currency, + }), + error: null, + }) + enqueue({ + data: { + ...company, + invoice_payment_accounts: {}, + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + }, + error: null, + }) + + const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + expect(mockRenderToBuffer).not.toHaveBeenCalled() + }, + ) + it('archives the rendered PDF as underlag linked to the journal entry', async () => { enqueue({ data: invoice, error: null }) // fetch invoice enqueue({ data: company, error: null }) // settings @@ -181,6 +224,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { expect(status).toBe(200) expect(body.success).toBe(true) expect(body.journal_entry_id).toBe('je-7') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({ supabase: mockSupabase, companyId: 'company-1', diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 3804ecd2..8b7b81af 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -17,6 +17,10 @@ import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { invoicePdfFilename } from '@/lib/invoices/pdf-filename' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' import { uploadDocument } from '@/lib/core/documents/document-service' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { @@ -97,14 +101,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } const customLines = linesResult.lines - // Assign invoice number now if this draft doesn't have one yet - try { - await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) - } catch (err) { - log.error('failed to assign invoice number on mark-sent', err as Error) - return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId }) - } - // Fetch full company settings for PDF rendering and accounting method const { data: settings, error: settingsError } = await supabase .from('company_settings') @@ -116,6 +112,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId }) } + const invoiceCurrency = (invoice as Invoice).currency + const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice) + if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) { + return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, { + requestId, + details: { currency: invoiceCurrency }, + }) + } + + // Assign the number only after all payment-instruction guards pass. + try { + await ensureInvoiceNumber(supabase, companyId, invoice as Invoice) + } catch (err) { + log.error('failed to assign invoice number on mark-sent', err as Error) + return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId }) + } + const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod const entityType = (settings.entity_type as EntityType) || 'enskild_firma' let originalInvoice: CreditNoteOriginalInvoice | undefined @@ -359,8 +372,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const } const { branding, company: renderCompany } = await prepareInvoicePdfRender( settings as CompanySettings, + renderableInvoice.currency, + { paymentAccountRequired }, ) - const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: renderableInvoice, @@ -425,14 +440,17 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( }) } - return NextResponse.json({ - success: true, - status: 'sent', - journal_entry_id: journalEntryId, - ...(partialFailures.length > 0 - ? { partial: true, partial_failures: partialFailures } - : {}), - }) + return NextResponse.json( + { + success: true, + status: 'sent', + journal_entry_id: journalEntryId, + ...(partialFailures.length > 0 + ? { partial: true, partial_failures: partialFailures } + : {}), + }, + { headers: { 'Cache-Control': 'private, no-store' } }, + ) }, { requireWrite: true }, ) diff --git a/app/api/invoices/[id]/pdf/__tests__/route.test.ts b/app/api/invoices/[id]/pdf/__tests__/route.test.ts index b94729d0..51b69f8a 100644 --- a/app/api/invoices/[id]/pdf/__tests__/route.test.ts +++ b/app/api/invoices/[id]/pdf/__tests__/route.test.ts @@ -37,7 +37,7 @@ import { GET } from '../route' describe('GET /api/invoices/[id]/pdf', () => { const user = { id: 'user-1', email: 'owner@example.test' } const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' }) - const company = makeCompanySettings({ company_name: 'Oppy Sverige' }) + const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' }) const invoice = makeInvoice({ id: 'invoice-1', invoice_number: '2621', @@ -77,6 +77,7 @@ describe('GET /api/invoices/[id]/pdf', () => { ) expect(response.status).toBe(404) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) it('returns a descriptive UTF-8 filename for the PDF download', async () => { @@ -91,5 +92,36 @@ describe('GET /api/invoices/[id]/pdf', () => { expect(response.status).toBe(200) expect(contentDispositionFilename(response.headers.get('Content-Disposition'))) .toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('returns 400 before rendering when a foreign payment account is missing', async () => { + enqueue({ data: { ...invoice, currency: 'EUR' }, error: null }) + enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null }) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf'), + createMockRouteParams({ id: 'invoice-1' }), + ) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(renderToBufferMock).not.toHaveBeenCalled() + }) + + it('marks PDF generation errors as private and non-cacheable', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + renderToBufferMock.mockRejectedValueOnce(new Error('render failed')) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf'), + createMockRouteParams({ id: 'invoice-1' }), + ) + + expect(response.status).toBe(500) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) }) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 447f907b..93f2d23e 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -7,12 +7,27 @@ import { invoicePdfFilename } from '@/lib/invoices/pdf-filename' import { contentDisposition } from '@/lib/api/content-disposition' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' + +const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' } + +function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 'invoice.pdf', - async (request, { supabase, companyId }, { params }) => { + async (request, { supabase, companyId, log, requestId }, { params }) => { const { id } = await params + // withRouteContext resolves companyId from the authenticated user's active + // membership. Explicit company filters remain mandatory defense in depth. + // Fetch invoice with customer and items const { data: invoice, error: invoiceError } = await supabase .from('invoices') @@ -26,7 +41,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( .single() if (invoiceError || !invoice) { - return NextResponse.json({ error: 'Invoice not found' }, { status: 404 }) + return NextResponse.json( + { error: 'Invoice not found' }, + { status: 404, headers: PRIVATE_NO_STORE_HEADERS }, + ) } // Fetch company settings @@ -37,7 +55,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( .single() if (companyError || !company) { - return NextResponse.json({ error: 'Company settings not found' }, { status: 404 }) + return NextResponse.json( + { error: 'Company settings not found' }, + { status: 404, headers: PRIVATE_NO_STORE_HEADERS }, + ) + } + + if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) { + return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, { + requestId, + details: { currency: (invoice as Invoice).currency }, + })) } // Sort items by sort_order @@ -50,6 +78,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( .from('invoices') .select('invoice_number') .eq('id', invoice.credited_invoice_id) + .eq('company_id', companyId) .single() if (originalInvoice) { @@ -61,8 +90,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // Generate PDF const { branding, company: renderCompany } = await prepareInvoicePdfRender( company as CompanySettings, + (invoice as Invoice).currency, + { paymentAccountRequired: invoiceRequiresPaymentAccount(invoice as Invoice) }, ) - const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, invoice as Invoice) const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ @@ -98,13 +129,14 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 'Content-Type': 'application/pdf', 'Content-Disposition': contentDisposition('attachment', filename), 'Content-Length': pdfBuffer.length.toString(), + 'Cache-Control': 'private, no-store', }, }) } catch (error) { - console.error('PDF generation error:', error) + log.error('invoice PDF generation failed', error, { requestId, invoiceId: id }) return NextResponse.json( { error: error instanceof Error ? getUserErrorMessage(error) : 'PDF generation failed' }, - { status: 500 } + { status: 500, headers: PRIVATE_NO_STORE_HEADERS } ) } }, diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 9b7f2e21..1419f73e 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -58,6 +58,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: { emailService: { sendEmail: (options: unknown) => Promise> } to: string | string[] cc?: string | string[] + bcc?: string | string[] subject: string html: string text: string @@ -69,6 +70,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: { ...(await input.emailService.sendEmail({ to: input.to, cc: input.cc, + bcc: input.bcc, subject: input.subject, html: input.html, text: input.text, @@ -135,7 +137,7 @@ import { POST } from '../route' describe('POST /api/invoices/[id]/send', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' }) - const company = makeCompanySettings({ accounting_method: 'accrual' }) + const company = makeCompanySettings({ accounting_method: 'accrual', bankgiro: '123-4567' }) const invoice = makeInvoice({ id: 'inv-1', status: 'draft', @@ -316,6 +318,26 @@ describe('POST /api/invoices/[id]/send', () => { expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') }) + it('returns 400 when the stored customer email is malformed', async () => { + enqueue({ + data: makeInvoice({ + id: 'inv-1', + customer: makeCustomer({ email: 'not-an-email' }), + items: [], + }), + error: null, + }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + it('returns 404 when company settings not found', async () => { enqueue({ data: invoice, error: null }) enqueue({ data: null, error: { message: 'Not found' } }) @@ -328,11 +350,155 @@ describe('POST /api/invoices/[id]/send', () => { expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING') }) + it.each(['SEK', 'EUR'] as const)( + 'does not allocate a number or send a %s invoice without a matching payment account', + async (currency) => { + const invoiceWithoutAccount = makeInvoice({ + ...invoice, + invoice_number: null, + currency, + }) + enqueue({ data: invoiceWithoutAccount, error: null }) + enqueue({ + data: { + ...company, + invoice_payment_accounts: {}, + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + }, + error: null, + }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }, + ) + + it('rejects custom recipients from a non-admin company member before allocation', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + enqueue({ data: { role: 'member' }, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { additional_cc: ['external@test.se'] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects a custom recipient collision before allocation', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + enqueue({ data: { role: 'admin' }, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { additional_cc: ['KUND@test.se'] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { collisions: Array<{ conflicts_with: string }> } } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.collisions).toEqual([ + expect.objectContaining({ conflicts_with: 'to' }), + ]) + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects a combined recipient set over the limit before allocation', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ + data: { + ...company, + invoice_email_cc_addresses: Array.from( + { length: 19 }, + (_, index) => `fixed-${index}@test.se`, + ), + invoice_email_bcc_addresses: [], + }, + error: null, + }) + enqueue({ data: { role: 'admin' }, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { additional_bcc: ['archive@test.se'] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { recipient_count: number } } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS') + expect(body.error.details.recipient_count).toBe(21) + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything()) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects fixed routing over the total limit without a custom-recipient role query', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ + data: { + ...company, + email: 'legacy@test.se', + invoice_email_cc_addresses: Array.from( + { length: 19 }, + (_, index) => `fixed-${index}@test.se`, + ), + invoice_email_bcc_addresses: ['archive@test.se'], + }, + error: null, + }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { recipient_count: number } } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS') + expect(body.error.details.recipient_count).toBe(21) + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + it('sends invoice email, updates status, creates journal entry for accrual', async () => { // Fetch invoice enqueue({ data: invoice, error: null }) // Fetch company settings - enqueue({ data: company, error: null }) + enqueue({ + data: { + ...company, + invoice_email_cc_addresses: ['fixed-copy@test.se'], + invoice_email_bcc_addresses: ['fixed-archive@test.se'], + }, + error: null, + }) + // Authorize the per-send CC and BCC additions. + enqueue({ data: { role: 'owner' }, error: null }) mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -344,22 +510,38 @@ describe('POST /api/invoices/[id]/send', () => { const emitSpy = vi.spyOn(eventBus, 'emit') - const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const request = createMockRequest('/api/invoices/inv-1/send', { + method: 'POST', + body: { + additional_cc: ['case-owner@test.se'], + additional_bcc: ['extra-archive@test.se'], + }, + }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) const { status, body } = await parseJsonResponse<{ success: boolean messageId: string + recipient_counts: { to: number; cc: number } }>(response) expect(status).toBe(200) expect(body.success).toBe(true) expect(body.messageId).toBe('msg-1') + expect(body.recipient_counts).toEqual({ to: 1, cc: 2 }) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith( - expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }), + expect.objectContaining({ + companyId: 'company-1', + invoiceId: 'inv-1', + cc: ['fixed-copy@test.se', 'case-owner@test.se'], + bcc: ['fixed-archive@test.se', 'extra-archive@test.se'], + }), ) expect(mockSendEmail).toHaveBeenCalledWith( expect.objectContaining({ - to: 'kund@test.se', + to: ['kund@test.se'], + cc: ['fixed-copy@test.se', 'case-owner@test.se'], + bcc: ['fixed-archive@test.se', 'extra-archive@test.se'], subject: 'Faktura F-2024001', }) ) @@ -381,6 +563,7 @@ describe('POST /api/invoices/[id]/send', () => { invoice_number: 'KR-F-2024001', status: 'draft', credited_invoice_id: 'inv-1', + currency: 'EUR', customer, items: (invoice.items ?? []).map((item) => ({ ...item, @@ -426,6 +609,9 @@ describe('POST /api/invoices/[id]/send', () => { }), ) expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + expect(InvoicePDF).toHaveBeenCalledWith( + expect.objectContaining({ originalInvoiceNumber: 'F-2024001' }), + ) expect(mockSendEmail).toHaveBeenCalledWith( expect.objectContaining({ attachments: [ @@ -516,7 +702,7 @@ describe('POST /api/invoices/[id]/send', () => { }) it('skips journal entry for cash method', async () => { - const cashCompany = makeCompanySettings({ accounting_method: 'cash' }) + const cashCompany = makeCompanySettings({ accounting_method: 'cash', bankgiro: '123-4567' }) enqueue({ data: invoice, error: null }) enqueue({ data: cashCompany, error: null }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 55caea14..bf79070e 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -27,7 +27,19 @@ import { InvoiceDeliverySnapshotError, } from '@/lib/invoices/invoice-deliveries' import { withRouteContext } from '@/lib/api/with-route-context' +import { SendInvoiceSchema } from '@/lib/api/schemas' import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines' +import { + EMAIL_PATTERN, + exceedsInvoiceEmailRecipientLimit, + findAdditionalInvoiceRecipientCollisions, + invoiceEmailRecipientCount, + resolveInvoiceEmailRecipients, +} from '@/lib/invoices/email-recipients' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' @@ -118,7 +130,18 @@ export const POST = withRouteContext( }) } - const linesResult = parseCustomIssuanceLines(rawBody) + const bodyResult = SendInvoiceSchema.safeParse(rawBody ?? {}) + if (!bodyResult.success) { + opLog.warn('send validation failed') + return NextResponse.json( + { error: 'Ogiltig förfrågan', details: bodyResult.error.flatten() }, + { status: 400 }, + ) + } + + const linesResult = parseCustomIssuanceLines( + bodyResult.data.lines ? { lines: bodyResult.data.lines } : undefined, + ) if (!linesResult.ok) { if (linesResult.error === 'invalid_body') { opLog.warn('send validation failed') @@ -147,7 +170,7 @@ export const POST = withRouteContext( } const customer = invoice.customer as Customer - if (!customer.email) { + if (!customer.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) { return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { requestId, details: { customerId: customer.id }, @@ -164,6 +187,72 @@ export const POST = withRouteContext( return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId }) } + const invoiceCurrency = (invoice as Invoice).currency + const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice) + if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) { + return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', opLog, { + requestId, + details: { currency: invoiceCurrency }, + }) + } + + const hasAdditionalRecipients = + (bodyResult.data.additional_cc?.length ?? 0) > 0 + || (bodyResult.data.additional_bcc?.length ?? 0) > 0 + // Fixed recipients are owner/admin-approved company routing and apply to + // every writable sender. Only a new per-send disclosure needs this fresh + // role check. See .compliance/authorization-policy.md. + if (hasAdditionalRecipients) { + const { data: membership, error: membershipError } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + + if (membershipError) { + opLog.error('failed to authorize custom invoice recipients', membershipError) + return errorResponseFromCode('INTERNAL_ERROR', opLog, { requestId }) + } + if (!membership || !['owner', 'admin'].includes(membership.role)) { + return errorResponseFromCode('FORBIDDEN', opLog, { + requestId, + details: { required_roles: ['owner', 'admin'] }, + }) + } + } + + const recipientInput = { + to: customer.email, + configuredCc: company.invoice_email_cc_addresses, + configuredBcc: company.invoice_email_bcc_addresses, + // This value comes from company settings or the authenticated sender. It + // is fixed routing, not an arbitrary request-controlled recipient. + legacyCc: company.email || user.email, + additionalCc: bodyResult.data.additional_cc, + additionalBcc: bodyResult.data.additional_bcc, + } + const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput) + if (recipientCollisions.length > 0) { + return errorResponseFromCode('VALIDATION_ERROR', opLog, { + requestId, + details: { field: 'recipients', collisions: recipientCollisions }, + }) + } + const recipients = resolveInvoiceEmailRecipients(recipientInput) + if (recipients.to.length === 0) { + return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { + requestId, + details: { customerId: customer.id }, + }) + } + if (exceedsInvoiceEmailRecipientLimit(recipients)) { + return errorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', opLog, { + requestId, + details: { recipient_count: invoiceEmailRecipientCount(recipients) }, + }) + } + const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order) let originalInvoice: CreditNoteOriginalInvoice | undefined @@ -190,7 +279,11 @@ export const POST = withRouteContext( const isFreshAllocation = !invoice.invoice_number if (isFreshAllocation) { try { - const preflight = await prepareInvoicePdfRender(company as CompanySettings) + const preflight = await prepareInvoicePdfRender( + company as CompanySettings, + (invoice as Invoice).currency, + { paymentAccountRequired }, + ) await renderToBuffer( InvoicePDF({ invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' }, @@ -253,8 +346,10 @@ export const POST = withRouteContext( const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const } const { branding, company: renderCompany } = await prepareInvoicePdfRender( company as CompanySettings, + renderableInvoice.currency, + { paymentAccountRequired }, ) - const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice) const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ @@ -285,7 +380,6 @@ export const POST = withRouteContext( isCreditNote, }) - const ccAddress = company.email || user.email const partialFailures: Array<{ step: string; reason: string }> = [] if (paymentLinkFailure) { // The failure string is a raw provider/DB message: log it, but the @@ -377,8 +471,9 @@ export const POST = withRouteContext( userId: user.id, invoiceId: id, deliveryId, - to: customer.email, - cc: ccAddress, + to: recipients.to, + cc: recipients.cc, + bcc: recipients.bcc, subject, html, text, @@ -575,15 +670,31 @@ export const POST = withRouteContext( }) } - return NextResponse.json({ - success: true, - message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`, - messageId: result.messageId, + opLog.info('invoice sent', { deliveryId: result.deliveryId, - ...(partialFailures.length > 0 - ? { partial: true, partial_failures: partialFailures } - : {}), + messageId: result.messageId, + recipientCounts: { + to: recipients.to.length, + cc: recipients.cc.length, + }, }) + + return NextResponse.json( + { + success: true, + message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email}`, + messageId: result.messageId, + deliveryId: result.deliveryId, + recipient_counts: { + to: recipients.to.length, + cc: recipients.cc.length, + }, + ...(partialFailures.length > 0 + ? { partial: true, partial_failures: partialFailures } + : {}), + }, + { headers: { 'Cache-Control': 'private, no-store' } }, + ) }, { requireWrite: true }, ) diff --git a/app/api/invoices/preview-pdf/__tests__/route.test.ts b/app/api/invoices/preview-pdf/__tests__/route.test.ts index 62ec22b2..b094c441 100644 --- a/app/api/invoices/preview-pdf/__tests__/route.test.ts +++ b/app/api/invoices/preview-pdf/__tests__/route.test.ts @@ -40,7 +40,7 @@ import { POST } from '../route' describe('POST /api/invoices/preview-pdf', () => { const user = { id: 'user-1', email: 'owner@example.test' } const customer = makeCustomer({ id: 'customer-1', name: 'Kund ÅÄÖ AB' }) - const company = makeCompanySettings({ company_name: 'Oppy Sverige' }) + const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' }) const validBody = { customer_id: customer.id, invoice_number: '2621', @@ -88,9 +88,11 @@ describe('POST /api/invoices/preview-pdf', () => { ) expect(response.status).toBe(400) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) it('returns 404 when the customer does not exist', async () => { + enqueue({ data: company, error: null }) enqueue({ data: null, error: { message: 'not found' } }) const response = await POST( @@ -99,11 +101,12 @@ describe('POST /api/invoices/preview-pdf', () => { ) expect(response.status).toBe(404) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) it('returns a descriptive UTF-8 filename for the PDF preview', async () => { - enqueue({ data: customer, error: null }) enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) const response = await POST( createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }), @@ -112,7 +115,41 @@ describe('POST /api/invoices/preview-pdf', () => { expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(contentDispositionFilename(response.headers.get('Content-Disposition'))) .toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf') }) + + it('returns 400 when a foreign payment account is missing', async () => { + enqueue({ data: company, error: null }) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { + method: 'POST', + body: { ...validBody, currency: 'EUR' }, + }), + createMockRouteParams({}), + ) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(renderToBufferMock).not.toHaveBeenCalled() + expect(mockSupabase.from).not.toHaveBeenCalledWith('customers') + }) + + it('marks preview generation errors as private and non-cacheable', async () => { + enqueue({ data: company, error: null }) + enqueue({ data: customer, error: null }) + renderToBufferMock.mockRejectedValueOnce(new Error('render failed')) + + const response = await POST( + createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }), + createMockRouteParams({}), + ) + + expect(response.status).toBe(500) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) }) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index da664f1e..030e58ae 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -7,6 +7,18 @@ import { getVatRules } from '@/lib/invoices/vat-rules' import { invoicePdfFilename } from '@/lib/invoices/pdf-filename' import { contentDisposition } from '@/lib/api/content-disposition' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' + +const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' } + +function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} /** * POST /api/invoices/preview-pdf @@ -14,7 +26,13 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy * Generates a preview PDF from form data without creating an invoice. * Returns the PDF as an inline blob for display in a new browser tab. */ -export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => { +export const POST = withRouteContext('invoice.preview_pdf', async (request, { + supabase, + user, + companyId, + log, + requestId, +}) => { const body = await request.json() const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body @@ -30,7 +48,40 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su })() if (!items || items.length === 0) { - return NextResponse.json({ error: 'Rader krävs' }, { status: 400 }) + return NextResponse.json( + { error: 'Rader krävs' }, + { status: 400, headers: PRIVATE_NO_STORE_HEADERS }, + ) + } + + const docType: InvoiceDocumentType = document_type || 'invoice' + const requestedCurrency = currency || 'SEK' + + // Fetch and validate company payment settings before customer data. The + // preview performs no writes, but a request that cannot be rendered should + // still stop before processing customer details. + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single() + + if (companyError || !company) { + return NextResponse.json( + { error: 'Företagsinställningar saknas' }, + { status: 404, headers: PRIVATE_NO_STORE_HEADERS }, + ) + } + + if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, { + currency: requestedCurrency, + document_type: docType, + credited_invoice_id: null, + })) { + return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, { + requestId, + details: { currency: requestedCurrency }, + })) } // When customer_id is omitted, only allow the synthetic preview if the @@ -47,7 +98,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su .eq('company_id', companyId) if (countError || (count ?? 0) > 0) { - return NextResponse.json({ error: 'Kunduppgifter krävs' }, { status: 400 }) + return NextResponse.json( + { error: 'Kunduppgifter krävs' }, + { status: 400, headers: PRIVATE_NO_STORE_HEADERS }, + ) } const nowIso = new Date().toISOString() @@ -85,26 +139,17 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su .single() if (customerError || !data) { - return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 }) + return NextResponse.json( + { error: 'Kunden hittades inte' }, + { status: 404, headers: PRIVATE_NO_STORE_HEADERS }, + ) } customer = data as Customer } - // Fetch company settings - const { data: company, error: companyError } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', companyId) - .single() - - if (companyError || !company) { - return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) - } - // VAT rules are customer-type-driven and only know the customer side. const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) - const docType: InvoiceDocumentType = document_type || 'invoice' const isDeliveryNote = docType === 'delivery_note' // VAT registration gate: mirror the server-side write gate @@ -154,7 +199,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su due_date: due_date || new Date().toISOString().split('T')[0], delivery_date: delivery_date || null, status: 'draft', - currency: currency || 'SEK', + currency: requestedCurrency, exchange_rate: null, exchange_rate_date: null, subtotal: isDeliveryNote ? 0 : subtotal, @@ -183,8 +228,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su try { const { branding, company: renderCompany } = await prepareInvoicePdfRender( company as CompanySettings, + previewInvoice.currency, + { paymentAccountRequired: invoiceRequiresPaymentAccount(previewInvoice) }, ) - const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, previewInvoice) const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice) const pdfBuffer = await renderToBuffer( InvoicePDF({ @@ -211,13 +258,14 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': contentDisposition('inline', filename), + 'Cache-Control': 'private, no-store', }, }) } catch (error) { - console.error('Preview PDF generation error:', error) + log.error('invoice preview PDF generation failed', error, { requestId }) return NextResponse.json( { error: 'Kunde inte generera PDF-förhandsgranskning' }, - { status: 500 } + { status: 500, headers: PRIVATE_NO_STORE_HEADERS } ) } }) diff --git a/app/api/reports/full-archive/__tests__/route.test.ts b/app/api/reports/full-archive/__tests__/route.test.ts index f07f62c8..b694bbaa 100644 --- a/app/api/reports/full-archive/__tests__/route.test.ts +++ b/app/api/reports/full-archive/__tests__/route.test.ts @@ -1,9 +1,28 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' -const { supabase } = createQueuedMockSupabase() +const { mockLogInfo, mockLogWarn, mockLogError } = vi.hoisted(() => ({ + mockLogInfo: vi.fn(), + mockLogWarn: vi.fn(), + mockLogError: vi.fn(), +})) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: mockLogInfo, + warn: mockLogWarn, + error: mockLogError, + child: vi.fn().mockReturnThis(), + }), +})) + +const { supabase, enqueue, reset } = createQueuedMockSupabase() +const { + supabase: archiveSupabase, + enqueue: enqueueArchive, + reset: resetArchive, +} = createQueuedMockSupabase() +const createServiceClientMock = vi.fn(() => archiveSupabase) const requireAuthMock = vi.fn() vi.mock('@/lib/auth/require-auth', () => ({ @@ -20,6 +39,10 @@ vi.mock('@/lib/reports/full-archive-export', () => ({ estimateArchiveSize: vi.fn(), })) +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => createServiceClientMock(), +})) + import { generateFullArchive, estimateArchiveSize, @@ -43,7 +66,11 @@ function unauthed() { beforeEach(() => { vi.clearAllMocks() + reset() + resetArchive() authed() + enqueue({ data: { role: 'admin' }, error: null }) + enqueueArchive({ data: { role: 'admin' }, error: null }) }) describe('GET /api/reports/full-archive', () => { @@ -56,6 +83,58 @@ describe('GET /api/reports/full-archive', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 for a member without archive-audit access', async () => { + reset() + enqueue({ data: { role: 'member' }, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/reports/full-archive')), + ) + + expect(status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(mockLogWarn).toHaveBeenCalledWith('full archive access denied', { + userId: 'user-1', + companyId: 'company-1', + role: 'member', + }) + expect(createServiceClientMock).not.toHaveBeenCalled() + expect(mockEstimate).not.toHaveBeenCalled() + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('rejects when the verified user is not a member of the selected company', async () => { + reset() + resetArchive() + enqueue({ data: { role: 'admin' }, error: null }) + enqueueArchive({ data: null, error: null }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/reports/full-archive')), + ) + + expect(status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(mockEstimate).not.toHaveBeenCalled() + expect(mockGenerate).not.toHaveBeenCalled() + }) + + it('returns 500 when the service-role membership verification fails', async () => { + reset() + resetArchive() + enqueue({ data: { role: 'admin' }, error: null }) + enqueueArchive({ data: null, error: new Error('database unavailable') }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await GET(createMockRequest('/api/reports/full-archive')), + ) + + expect(status).toBe(500) + expect(body.error.code).toBe('INTERNAL_ERROR') + expect(mockEstimate).not.toHaveBeenCalled() + expect(mockGenerate).not.toHaveBeenCalled() + }) + it('returns estimate-only response when ?estimate=1', async () => { mockEstimate.mockResolvedValue({ total_bytes: 10_000_000, @@ -63,23 +142,24 @@ describe('GET /api/reports/full-archive', () => { document_count: 7, }) + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { estimate: '1', scope: 'all' }, + }), + ) const { status, body } = await parseJsonResponse<{ data: { total_bytes: number size_limit_bytes: number within_limit: boolean } - }>( - await GET( - createMockRequest('/api/reports/full-archive', { - searchParams: { estimate: '1', scope: 'all' }, - }) - ) - ) + }>(response) expect(status).toBe(200) expect(body.data.total_bytes).toBe(10_000_000) expect(body.data.within_limit).toBe(true) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'company-1', 'all', undefined) expect(mockGenerate).not.toHaveBeenCalled() }) @@ -102,6 +182,7 @@ describe('GET /api/reports/full-archive', () => { }>(response) expect(status).toBe(413) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(body.error).toBe('archive_too_large') expect(body.size_bytes).toBe(200 * 1024 * 1024) expect(body.size_limit_bytes).toBe(80 * 1024 * 1024) @@ -123,7 +204,12 @@ describe('GET /api/reports/full-archive', () => { ) expect(response.status).toBe(200) + expect(mockLogInfo).toHaveBeenCalledWith('full archive generated', expect.objectContaining({ + filename: expect.stringMatching(/^arkiv_full_company-1_\d{8}\.zip$/), + sizeBytes: 1024, + })) expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(mockGenerate).toHaveBeenCalledWith( expect.anything(), 'company-1', @@ -172,15 +258,15 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 400 when scope=period without period_id', async () => { - const { status, body } = await parseJsonResponse( - await GET( - createMockRequest('/api/reports/full-archive', { - searchParams: { scope: 'period' }, - }) - ) + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'period' }, + }), ) + const { status, body } = await parseJsonResponse(response) expect(status).toBe(400) expect(body).toEqual({ error: 'period_id is required when scope=period' }) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') expect(mockGenerate).not.toHaveBeenCalled() expect(mockEstimate).not.toHaveBeenCalled() }) @@ -193,14 +279,14 @@ describe('GET /api/reports/full-archive', () => { }) mockGenerate.mockRejectedValue(new Error('Fiscal period not found')) - const { status, body } = await parseJsonResponse( - await GET( - createMockRequest('/api/reports/full-archive', { - searchParams: { scope: 'period', period_id: 'nope' }, - }) - ) + const response = await GET( + createMockRequest('/api/reports/full-archive', { + searchParams: { scope: 'period', period_id: 'nope' }, + }), ) + const { status, body } = await parseJsonResponse(response) expect(status).toBe(404) expect(body).toEqual({ error: 'Något gick fel. Försök igen.' }) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') }) }) diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index bd40a125..ae0f8865 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -6,13 +6,22 @@ import { } from '@/lib/reports/full-archive-export' import { withRouteContext } from '@/lib/api/with-route-context' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createServiceClient } from '@/lib/supabase/server' export const runtime = 'nodejs' export const maxDuration = 300 const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 +const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' } -export const GET = withRouteContext('report.full_archive', async (request, { supabase, companyId }) => { +function privateNoStore(response: NextResponse): NextResponse { + response.headers.set('Cache-Control', 'private, no-store') + return response +} + +export const GET = withRouteContext('report.full_archive', async (request, ctx) => { + const { supabase, companyId, user, log, requestId } = ctx const { searchParams } = new URL(request.url) const scopeParam = searchParams.get('scope') const periodId = searchParams.get('period_id') @@ -26,26 +35,85 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup if (scope === 'period' && !periodId) { return NextResponse.json( { error: 'period_id is required when scope=period' }, - { status: 400 } + { status: 400, headers: PRIVATE_NO_STORE_HEADERS } ) } + const { data: membership, error: membershipError } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + if (membershipError) { + log.error('failed to authorize full archive export', membershipError, { + userId: user.id, + companyId, + }) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + if (!membership || !['owner', 'admin'].includes(membership.role)) { + log.warn('full archive access denied', { + userId: user.id, + companyId, + role: membership?.role ?? null, + }) + return privateNoStore(errorResponseFromCode('FORBIDDEN', log, { + requestId, + details: { required_roles: ['owner', 'admin'] }, + })) + } + + // The complete statutory archive includes exact delivery evidence from all + // company senders. Only this owner/admin server path receives a service-role + // client; normal delivery history remains data-minimized by RLS. companyId + // comes from withRouteContext's authenticated active-company resolution, + // never from a request parameter, and is verified again below. + const archiveClient = createServiceClient() + const { data: verifiedMembership, error: verificationError } = await archiveClient + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + if (verificationError) { + log.error('failed to verify full archive export with service role', verificationError, { + userId: user.id, + companyId, + }) + return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId })) + } + if (!verifiedMembership || !['owner', 'admin'].includes(verifiedMembership.role)) { + log.warn('full archive service-role verification denied', { + userId: user.id, + companyId, + role: verifiedMembership?.role ?? null, + }) + return privateNoStore(errorResponseFromCode('FORBIDDEN', log, { + requestId, + details: { required_roles: ['owner', 'admin'] }, + })) + } + try { const estimate = await estimateArchiveSize( - supabase, + archiveClient, companyId, scope, scope === 'period' ? periodId! : undefined ) if (estimateOnly) { - return NextResponse.json({ - data: { - ...estimate, - size_limit_bytes: SIZE_LIMIT_BYTES, - within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES, + return NextResponse.json( + { + data: { + ...estimate, + size_limit_bytes: SIZE_LIMIT_BYTES, + within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES, + }, }, - }) + { headers: PRIVATE_NO_STORE_HEADERS }, + ) } if (includeDocuments && estimate.total_bytes > SIZE_LIMIT_BYTES) { @@ -55,12 +123,12 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup size_bytes: estimate.total_bytes, size_limit_bytes: SIZE_LIMIT_BYTES, }, - { status: 413 } + { status: 413, headers: PRIVATE_NO_STORE_HEADERS } ) } const zipBuffer = await generateFullArchive( - supabase, + archiveClient, companyId, scope === 'period' ? { scope: 'period', period_id: periodId!, include_documents: includeDocuments } @@ -72,17 +140,36 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup ? `arkiv_${periodId}.zip` : `arkiv_full_${companyId}_${formatDateStamp(new Date())}.zip` + log.info('full archive generated', { + userId: user.id, + companyId, + scope, + includeDocuments, + filename, + sizeBytes: zipBuffer.byteLength, + }) + return new NextResponse(zipBuffer, { status: 200, headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'private, no-store', }, }) } catch (err) { + log.error('full archive generation failed', err as Error, { + userId: user.id, + companyId, + scope, + includeDocuments, + }) const message = err instanceof Error ? err.message : 'Failed to generate archive' const status = message.includes('not found') ? 404 : 500 - return NextResponse.json({ error: getErrorMessage(err) }, { status }) + return NextResponse.json( + { error: getErrorMessage(err) }, + { status, headers: PRIVATE_NO_STORE_HEADERS }, + ) } }) diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts index 8c27f70a..bbe9b5f0 100644 --- a/app/api/settings/__tests__/route.test.ts +++ b/app/api/settings/__tests__/route.test.ts @@ -97,6 +97,118 @@ describe('PUT /api/settings', () => { expect(deadlineMocks.regenerate).not.toHaveBeenCalled() }) + it('updates invoice email recipients and payment accounts', async () => { + const updates = { + invoice_email_cc_addresses: ['info@example.com', 'owner@example.com'], + invoice_email_bcc_addresses: ['archive@example.com'], + invoice_payment_accounts: { + EUR: { + bank_name: 'Example Bank', + iban: 'SE0022222222222222222222', + bic: 'EXAMSESS', + }, + }, + } + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, + { data: { role: 'admin' } }, + { data: { id: 's1', ...updates } }, + { data: null, count: 5 }, + ]) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: updates, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: typeof updates }>(response) + + expect(status).toBe(200) + expect(body.data).toMatchObject(updates) + }) + + it('rejects fixed invoice recipient changes from a regular member', async () => { + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, + { data: { role: 'member' }, error: null }, + ]) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { invoice_email_bcc_addresses: ['archive@example.com'] }, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { required_roles?: string[] } } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(body.error.details?.required_roles).toEqual(['owner', 'admin']) + expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([ + 'company_settings', + 'company_members', + ]) + }) + + it('rejects invoice payment instruction changes from a regular member', async () => { + enqueueMany([ + { data: { entity_type: 'aktiebolag', onboarding_complete: true } }, + { data: { role: 'member' }, error: null }, + ]) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { + invoice_payment_accounts: { + SEK: { bankgiro: '123-4567' }, + }, + bankgiro: '123-4567', + }, + }), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { required_roles?: string[] } } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(body.error.details?.required_roles).toEqual(['owner', 'admin']) + expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([ + 'company_settings', + 'company_members', + ]) + }) + + it('rejects invalid invoice recipients with otherwise valid payment accounts', async () => { + enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } }) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { + invoice_email_cc_addresses: ['not-an-email'], + invoice_payment_accounts: { + EUR: { bank_name: 'Example Bank', iban: 'SE0022222222222222222222' }, + }, + }, + }), { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('rejects a foreign payment account without IBAN with valid recipients', async () => { + enqueue({ data: { entity_type: 'aktiebolag', onboarding_complete: true } }) + + const response = await PUT(createMockRequest('/api/settings', { + method: 'PUT', + body: { + invoice_email_cc_addresses: ['billing@example.com'], + invoice_payment_accounts: { EUR: { bank_name: 'Example Bank' } }, + }, + }), { params: Promise.resolve({}) }) + + expect(response.status).toBe(400) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + it('regenerates deadlines when unchanged tax settings are saved', async () => { const settings = { company_id: 'company-1', diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 75d7590c..8af61f4f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -10,6 +10,7 @@ import { import { validateBody } from '@/lib/api/validate' import { UpdateSettingsSchema } from '@/lib/api/schemas' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' export const GET = withRouteContext( 'settings.get', @@ -43,7 +44,7 @@ export const GET = withRouteContext( export const PUT = withRouteContext( 'settings.update', - async (request, { supabase, companyId, log }) => { + async (request, { supabase, companyId, log, requestId, user }) => { // Fetch current settings to check for tax-relevant changes const { data: oldSettings } = await supabase .from('company_settings') @@ -55,6 +56,39 @@ export const PUT = withRouteContext( if (!validation.success) return validation.response const body = validation.data + const changesInvoiceEmailRecipients = + body.invoice_email_cc_addresses !== undefined + || body.invoice_email_bcc_addresses !== undefined + const changesInvoicePaymentInstructions = + body.invoice_payment_accounts !== undefined + || body.bank_name !== undefined + || body.clearing_number !== undefined + || body.account_number !== undefined + || body.bankgiro !== undefined + || body.plusgiro !== undefined + || body.swish !== undefined + || body.iban !== undefined + || body.bic !== undefined + if (changesInvoiceEmailRecipients || changesInvoicePaymentInstructions) { + const { data: membership, error: membershipError } = await supabase + .from('company_members') + .select('role') + .eq('company_id', companyId) + .eq('user_id', user.id) + .maybeSingle() + + if (membershipError) { + log.error('failed to authorize restricted invoice settings', membershipError) + return errorResponseFromCode('INTERNAL_ERROR', log, { requestId }) + } + if (!membership || !['owner', 'admin'].includes(membership.role)) { + return errorResponseFromCode('FORBIDDEN', log, { + requestId, + details: { required_roles: ['owner', 'admin'] }, + }) + } + } + const reminderDays = [ body.reminder_days_level_1 ?? oldSettings?.reminder_days_level_1 ?? 15, body.reminder_days_level_2 ?? oldSettings?.reminder_days_level_2 ?? 30, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts index 9d842e6d..465ba1da 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -50,11 +50,13 @@ import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-key import { createInvoiceJournalEntry as mockedCreateEntry, } from '@/lib/bookkeeping/invoice-entries' +import { ensureInvoiceNumber as mockedEnsureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { POST as markSent } from '../route' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType const mockCreateJournalEntry = mockedCreateEntry as ReturnType +const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType type MockResult = { data?: unknown; error?: unknown } function makeFlexibleSupabase(byTable: Record) { @@ -145,7 +147,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { { data: DRAFT_INVOICE, error: null }, { data: SENT_INVOICE, error: null }, ], - company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + company_settings: { + data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' }, + error: null, + }, }), ) @@ -191,6 +196,65 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { expect(body.error.details.current_status).toBe('sent') }) + it.each([ + ['missing row', { data: null, error: null }], + ['database error', { data: null, error: { message: 'connection reset' } }], + ])('fails closed when company settings have a %s', async (_label, settingsResult) => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: settingsResult, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING') + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it.each(['SEK', 'EUR'] as const)( + 'rejects a %s invoice without a payment account before number allocation', + async (currency) => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, currency }, error: null }, + company_settings: { + data: { + accounting_method: 'accrual', + entity_type: 'enskild_firma', + invoice_payment_accounts: {}, + }, + error: null, + }, + }), + ) + + const res = await markSent( + makeMarkSentRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-sent`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }, + ) + it('rejects delivery notes with VALIDATION_ERROR (regardless of status)', async () => { // Critical: the delivery-note guard must run BEFORE the status check // so a sent delivery note still returns 400 (per the documented @@ -268,7 +332,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { { data: DRAFT_INVOICE, error: null }, { data: SENT_INVOICE, error: null }, ], - company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + company_settings: { + data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' }, + error: null, + }, }), ) // Force the journal-entry generator to throw. @@ -336,7 +403,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { makeFlexibleSupabase({ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, invoices: { data: DRAFT_INVOICE, error: null }, - company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + company_settings: { + data: { accounting_method: 'accrual', entity_type: 'enskild_firma', bankgiro: '123-4567' }, + error: null, + }, }), ) @@ -366,7 +436,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => { { data: DRAFT_INVOICE, error: null }, { data: SENT_INVOICE, error: null }, ], - company_settings: { data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }, + company_settings: { + data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' }, + error: null, + }, }), ) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts index c686ac8f..785e7995 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts @@ -42,8 +42,11 @@ import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries' +import { + hasRequiredInvoicePaymentAccount, +} from '@/lib/invoices/payment-accounts' import { eventBus } from '@/lib/events' -import type { EntityType, Invoice } from '@/types' +import type { CompanySettings, EntityType, Invoice } from '@/types' // Explicit projection: drops user_id, company_id (internal scoping). // default_dimensions must stay in this projection: the fetched row feeds @@ -204,16 +207,33 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - // Fetch company settings (accounting method + entity type drive the - // journal-entry decision). Best-effort: without settings we default - // to enskild_firma / accrual which matches the dashboard default. - const { data: settings } = await ctx.supabase + // Fetch company settings before number allocation. Besides the accounting + // decision, payable invoices need a currency-matching account. + const { data: settings, error: settingsError } = await ctx.supabase .from('company_settings') - .select('accounting_method, entity_type') + .select('accounting_method, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic') .eq('company_id', ctx.companyId!) .maybeSingle() - const accountingMethod = (settings as { accounting_method?: string } | null)?.accounting_method ?? 'accrual' - const entityType = ((settings as { entity_type?: string } | null)?.entity_type ?? 'enskild_firma') as EntityType + if (settingsError || !settings) { + if (settingsError) { + ctx.log.error('invoices.mark-sent: company settings fetch failed', settingsError as Error, { + invoiceId, + companyId: ctx.companyId, + }) + } + return v1ErrorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', ctx.log, { + requestId: ctx.requestId, + }) + } + const companySettings = settings as CompanySettings + if (!hasRequiredInvoicePaymentAccount(companySettings, typed)) { + return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, { + requestId: ctx.requestId, + details: { currency: typed.currency }, + }) + } + const accountingMethod = companySettings.accounting_method ?? 'accrual' + const entityType = (companySettings.entity_type ?? 'enskild_firma') as EntityType const isRealInvoice = !typed.document_type || typed.document_type === 'invoice' const wouldCreateJournalEntry = isRealInvoice && accountingMethod === 'accrual' diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts index ad71bfe6..b2242bf3 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts @@ -110,6 +110,7 @@ const COMPANY_SETTINGS = { company_name: 'Test AB', entity_type: 'enskild_firma', accounting_method: 'accrual', + bankgiro: '123-4567', } beforeEach(() => { @@ -142,6 +143,7 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => { expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('application/pdf') + expect(res.headers.get('Cache-Control')).toBe('private, no-store') expect(contentDispositionFilename(res.headers.get('Content-Disposition'))) .toBe('Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf') expect(res.headers.get('X-Request-Id')).toMatch(/^req_/) @@ -239,6 +241,26 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => { expect(body.error.code).toBe('INVOICE_PDF_RENDER_FAILED') }) + it('returns 400 when a foreign payment account is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...SENT_INVOICE, currency: 'EUR' }, error: null }, + company_settings: { data: { ...COMPANY_SETTINGS, invoice_payment_accounts: {} }, error: null }, + }), + ) + + const res = await pdf( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/pdf`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(mockRender).not.toHaveBeenCalled() + }) + it('returns 400 VALIDATION_ERROR for non-UUID id', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts index 7124180d..52b101cc 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts @@ -26,6 +26,10 @@ import { contentDisposition } from '@/lib/api/content-disposition' import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' const INVOICE_PDF_COLUMNS = @@ -131,6 +135,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } }) } + if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, typed)) { + return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, { + requestId: ctx.requestId, + details: { currency: typed.currency }, + }) + } + const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order) // Credit-note back-reference per ML 17 kap 22-23§. Best-effort: if the @@ -153,8 +164,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } try { const { branding, company: renderCompany } = await prepareInvoicePdfRender( company as CompanySettings, + typed.currency, + { paymentAccountRequired: invoiceRequiresPaymentAccount(typed) }, ) - const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, typed as Invoice) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, typed as Invoice) pdfBuffer = await renderToBuffer( InvoicePDF({ invoice: typed as Invoice, @@ -194,6 +207,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } 'Content-Type': 'application/pdf', 'Content-Disposition': contentDisposition('attachment', filename), 'Content-Length': String(pdfBuffer.length), + 'Cache-Control': 'private, no-store', 'X-Request-Id': ctx.requestId, }, }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index 110246e7..31b49bc4 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -68,6 +68,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: { emailService: { sendEmail: (options: unknown) => Promise> } to: string | string[] cc?: string | string[] + bcc?: string | string[] subject: string html: string text: string @@ -79,6 +80,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: { ...(await input.emailService.sendEmail({ to: input.to, cc: input.cc, + bcc: input.bcc, subject: input.subject, html: input.html, text: input.text, @@ -127,10 +129,12 @@ vi.mock('@/lib/entitlements/has-capability', () => ({ import { InvoicePDF } from '@/lib/invoices/pdf-template' import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { ensureInvoiceNumber as mockedEnsureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { POST as sendInvoice } from '../route' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType type MockResult = { data?: unknown; error?: unknown } function makeFlexibleSupabase(byTable: Record) { @@ -168,13 +172,27 @@ const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' const USER_ID = 'user-1' -function makeRequest(url: string): Request { +function makeRequest(url: string, body?: unknown): Request { return new Request(url, { method: 'POST', headers: { Authorization: 'Bearer test-fixture-not-a-real-key', 'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }, + body: body === undefined ? undefined : JSON.stringify(body), + }) +} + +function makeRawRequest(url: string, body: string): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-3030-4abc-8def-1234567890ab', + }, + body, }) } function detailParams(companyId: string, id: string) { @@ -209,6 +227,9 @@ const COMPANY_SETTINGS = { company_id: COMPANY_ID, company_name: 'Test AB', email: 'support@test-ab.example', + invoice_email_cc_addresses: ['fixed-copy@test-ab.example'], + invoice_email_bcc_addresses: ['fixed-archive@test-ab.example'], + bankgiro: '123-4567', accounting_method: 'accrual', entity_type: 'enskild_firma', } @@ -228,6 +249,154 @@ beforeEach(() => { }) describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { + it('returns 401 without an API key', async () => { + const res = await sendInvoice( + new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + { method: 'POST' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error.code).toBe('UNAUTHORIZED') + }) + + it('returns 404 when the invoice does not exist', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('rejects a malformed stored customer email before allocation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { + data: { + ...DRAFT_INVOICE, + customer: { ...DRAFT_INVOICE.customer, email: 'not-an-email' }, + }, + error: null, + }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it.each(['SEK', 'EUR'] as const)( + 'rejects a %s invoice without a payment account before number allocation', + async (currency) => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, currency }, error: null }, + company_settings: { + data: { + ...COMPANY_SETTINGS, + invoice_payment_accounts: {}, + clearing_number: null, + account_number: null, + bankgiro: null, + plusgiro: null, + swish: null, + iban: null, + }, + error: null, + }, + }), + ) + + const res = await sendInvoice( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING') + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }, + ) + + it('returns VALIDATION_ERROR for malformed JSON', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await sendInvoice( + makeRawRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + '{"additional_cc":[', + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it.each([ + ['invalid additional_cc', { additional_cc: ['not-an-email'] }], + [ + 'oversized additional_cc', + { additional_cc: Array.from({ length: 21 }, (_, index) => `copy-${index}@example.test`) }, + ], + ['invalid additional_bcc', { additional_bcc: ['not-an-email'] }], + [ + 'oversized additional_bcc', + { additional_bcc: Array.from({ length: 21 }, (_, index) => `archive-${index}@example.test`) }, + ], + ])('returns VALIDATION_ERROR for %s', async (_label, requestBody) => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + requestBody, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + it('sends a draft invoice end-to-end and returns 200 with messageId', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ @@ -241,7 +410,13 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { ) const res = await sendInvoice( - makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`), + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + { + additional_cc: ['case-owner@test-ab.example'], + additional_bcc: ['extra-archive@test-ab.example'], + }, + ), detailParams(COMPANY_ID, INVOICE_ID), ) @@ -251,13 +426,21 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { expect(body.data.invoice_number).toBe('2026-0042') expect(body.data.message_id).toBe('re_abc123') expect(body.data.sent_to).toBe('billing@acme.test') + expect(body.data.cc_addresses).toEqual([ + 'fixed-copy@test-ab.example', + 'case-owner@test-ab.example', + ]) + expect(body.data).not.toHaveProperty('bcc_addresses') expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj') + expect(res.headers.get('Cache-Control')).toBe('private, no-store') expect(mockSendEmail).toHaveBeenCalledTimes(1) expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith( expect.objectContaining({ companyId: COMPANY_ID, invoiceId: INVOICE_ID }), ) expect(mockSendEmail).toHaveBeenCalledWith( expect.objectContaining({ + cc: ['fixed-copy@test-ab.example', 'case-owner@test-ab.example'], + bcc: ['fixed-archive@test-ab.example', 'extra-archive@test-ab.example'], attachments: [ expect.objectContaining({ filename: 'Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf', @@ -267,6 +450,130 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { ) }) + it('rejects custom recipients from a non-admin company member', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'member' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + { additional_bcc: ['external@test-ab.example'] }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('FORBIDDEN') + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects a custom recipient collision before allocation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { data: COMPANY_SETTINGS, error: null }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + { additional_cc: ['BILLING@acme.test'] }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.collisions).toEqual([ + expect.objectContaining({ conflicts_with: 'to' }), + ]) + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects a combined recipient set over the limit before allocation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { + data: { + ...COMPANY_SETTINGS, + invoice_email_cc_addresses: ['fixed-copy@test-ab.example'], + invoice_email_bcc_addresses: ['fixed-archive@test-ab.example'], + }, + error: null, + }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + { + additional_cc: Array.from( + { length: 18 }, + (_, index) => `additional-${index}@example.test`, + ), + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS') + expect(body.error.details.recipient_count).toBe(21) + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('rejects fixed routing over the total limit without per-send additions', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: DRAFT_INVOICE, error: null }, + company_settings: { + data: { + ...COMPANY_SETTINGS, + invoice_email_cc_addresses: Array.from( + { length: 19 }, + (_, index) => `fixed-${index}@example.test`, + ), + invoice_email_bcc_addresses: ['archive@example.test'], + }, + error: null, + }, + }), + ) + + const res = await sendInvoice( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/send`, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS') + expect(body.error.details.recipient_count).toBe(21) + expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled() + expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }) + it('returns 503 when email service is not configured', async () => { mockIsConfigured.mockReturnValue(false) mockServiceClient.mockReturnValue( @@ -388,7 +695,10 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => { expect(body.data.dry_run).toBe(true) expect(body.data.preview.status).toBe('sent') expect(body.data.preview.would_send_to).toBe('billing@acme.test') - expect(body.data.preview.would_cc).toBe('support@test-ab.example') + expect(body.data.preview.would_cc).toBe('fixed-copy@test-ab.example') + expect(body.data.preview.would_cc_addresses).toEqual(['fixed-copy@test-ab.example']) + expect(body.data.preview).not.toHaveProperty('would_bcc_addresses') + expect(res.headers.get('Cache-Control')).toBe('private, no-store') expect(body.data.preview.preflight_pdf_render).toBe('ok') expect(mockSendEmail).not.toHaveBeenCalled() }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts index 93aab221..0ea58c95 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts @@ -64,6 +64,18 @@ import { sendTrackedInvoiceEmail, InvoiceDeliverySnapshotError, } from '@/lib/invoices/invoice-deliveries' +import { + EMAIL_PATTERN, + exceedsInvoiceEmailRecipientLimit, + MAX_INVOICE_EMAIL_COPY_RECIPIENTS, + findAdditionalInvoiceRecipientCollisions, + invoiceEmailRecipientCount, + resolveInvoiceEmailRecipients, +} from '@/lib/invoices/email-recipients' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' import { eventBus } from '@/lib/events' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' @@ -71,6 +83,21 @@ import { CAPABILITY } from '@/lib/entitlements/keys' import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' +const InvoiceSendBody = z.object({ + additional_cc: z.array(z.string().trim().pipe(z.email().max(254))) + .max(MAX_INVOICE_EMAIL_COPY_RECIPIENTS) + .optional(), + additional_bcc: z.array(z.string().trim().pipe(z.email().max(254))) + .max(MAX_INVOICE_EMAIL_COPY_RECIPIENTS) + .optional(), +}).refine( + (data) => ( + (data.additional_cc?.length ?? 0) + (data.additional_bcc?.length ?? 0) + <= MAX_INVOICE_EMAIL_COPY_RECIPIENTS + ), + { path: ['additional_cc'] }, +) + const InvoiceSendResponse = z.object({ id: z.string().uuid(), invoice_number: z.string(), @@ -78,7 +105,10 @@ const InvoiceSendResponse = z.object({ total: z.number(), message_id: z.string().nullable(), sent_to: z.string(), - cc: z.string().nullable(), + cc: z.string().nullable().describe( + 'Deprecated compatibility field containing only the first CC recipient. Use cc_addresses for the complete delivery list.', + ), + cc_addresses: z.array(z.string()), journal_entry_id: z.string().uuid().nullable(), warnings: z .array(z.object({ code: z.string(), message: z.string() })) @@ -103,8 +133,15 @@ registerEndpoint({ 'A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED): its F-series number is preserved for compliance but the document is not a valid faktura.', 'Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route).', 'After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless.', + 'additional_cc and additional_bcc require the API key user to be an owner or admin of the company.', + 'The deprecated cc response field contains only the first address. Use cc_addresses for the complete CC list.', + 'BCC recipients are retained only in the restricted delivery archive and are omitted from normal and dry-run responses.', ], example: { + request: { + additional_cc: ['case-owner@company.test'], + additional_bcc: ['invoice-archive@company.test'], + }, response: { data: { id: '0e9c…', @@ -114,6 +151,7 @@ registerEndpoint({ message_id: 're_abc123', sent_to: 'finance@acme.test', cc: 'billing@gnubok-user.test', + cc_addresses: ['billing@gnubok-user.test'], journal_entry_id: '7b3a…', }, meta: { request_id: 'req_…', api_version: '2026-05-12' }, @@ -124,14 +162,28 @@ registerEndpoint({ idempotent: true, reversible: false, dryRunSupported: true, + request: { body: InvoiceSendBody }, response: { success: dataEnvelope(InvoiceSendResponse) }, }) export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( 'invoices.send', - async (_request, ctx, params) => { + async (request, ctx, params) => { const { id } = await params.params + let rawBody: unknown = {} + const bodyText = await request.text() + if (bodyText) { + try { + rawBody = JSON.parse(bodyText) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + } + const idParse = z.string().uuid().safeParse(id) if (!idParse.success) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { @@ -211,6 +263,19 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + const bodyResult = InvoiceSendBody.safeParse(rawBody) + if (!bodyResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: bodyResult.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })), + }, + }) + } + // Reject delivery notes: they have a different (D-series) lifecycle. if (typed.document_type === 'delivery_note') { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { @@ -253,7 +318,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // Step 2: customer email. const customer = typed.customer - if (!customer?.email) { + if (!customer?.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) { return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, { requestId: ctx.requestId, details: { customer_id: typed.customer_id }, @@ -278,7 +343,71 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } const settings = company as CompanySettings & { accounting_method?: string } + const paymentAccountRequired = invoiceRequiresPaymentAccount(typed) + if (!hasRequiredInvoicePaymentAccount(settings, typed)) { + return v1ErrorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', ctx.log, { + requestId: ctx.requestId, + details: { currency: typed.currency }, + }) + } + const hasAdditionalRecipients = + (bodyResult.data.additional_cc?.length ?? 0) > 0 + || (bodyResult.data.additional_bcc?.length ?? 0) > 0 + // Fixed recipients are owner/admin-approved company routing. A fresh role + // check is required only when this request introduces another recipient. + if (hasAdditionalRecipients) { + const { data: membership, error: membershipError } = await ctx.supabase + .from('company_members') + .select('role') + .eq('company_id', ctx.companyId!) + .eq('user_id', ctx.userId) + .maybeSingle() + + if (membershipError) { + ctx.log.error('invoices.send: failed to authorize custom recipients', membershipError) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + }) + } + if (!membership || !['owner', 'admin'].includes(membership.role)) { + return v1ErrorResponseFromCode('FORBIDDEN', ctx.log, { + requestId: ctx.requestId, + details: { required_roles: ['owner', 'admin'] }, + }) + } + } + + const recipientInput = { + to: customer.email, + configuredCc: settings.invoice_email_cc_addresses, + configuredBcc: settings.invoice_email_bcc_addresses, + // The company email is fixed routing, not an arbitrary + // request-controlled recipient. + legacyCc: settings.email, + additionalCc: bodyResult.data.additional_cc, + additionalBcc: bodyResult.data.additional_bcc, + } + const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput) + if (recipientCollisions.length > 0) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'recipients', collisions: recipientCollisions }, + }) + } + const recipients = resolveInvoiceEmailRecipients(recipientInput) + if (recipients.to.length === 0) { + return v1ErrorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', ctx.log, { + requestId: ctx.requestId, + details: { customer_id: typed.customer_id }, + }) + } + if (exceedsInvoiceEmailRecipientLimit(recipients)) { + return v1ErrorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', ctx.log, { + requestId: ctx.requestId, + details: { recipient_count: invoiceEmailRecipientCount(recipients) }, + }) + } const items = (typed.items ?? []).slice().sort((a, b) => a.sort_order - b.sort_order) // Credit notes are rejected above, so originalInvoiceNumber is never // needed on this code path. Kept undefined to satisfy the InvoicePDF @@ -290,7 +419,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string const isFreshAllocation = !typed.invoice_number if (isFreshAllocation) { try { - const preflight = await prepareInvoicePdfRender(settings) + const preflight = await prepareInvoicePdfRender(settings, typed.currency, { + paymentAccountRequired, + }) await renderToBuffer( InvoicePDF({ invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' }, @@ -315,13 +446,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string if (ctx.dryRun) { // Dry-run stops here. Validated everything that doesn't have side // effects; preview the would-be sent state. - return dryRunPreview( + const response = dryRunPreview( { ...typed, status: 'sent' as const, invoice_number: typed.invoice_number ?? '(allocated atomically on commit)', would_send_to: customer.email, - would_cc: settings.email || null, + would_cc: recipients.cc[0] ?? null, + would_cc_addresses: recipients.cc, would_create_journal_entry: (!typed.document_type || typed.document_type === 'invoice') && (settings.accounting_method ?? 'accrual') === 'accrual', @@ -330,6 +462,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }, { requestId: ctx.requestId, log: ctx.log }, ) + response.headers.set('Cache-Control', 'private, no-store') + return response } let deliveryId: string @@ -419,8 +553,12 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string let pdfBuffer: Buffer try { - const { branding, company: renderCompany } = await prepareInvoicePdfRender(settings) - const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice) + const { branding, company: renderCompany } = await prepareInvoicePdfRender( + settings, + renderableInvoice.currency, + { paymentAccountRequired }, + ) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice) const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice) pdfBuffer = await renderToBuffer( InvoicePDF({ @@ -457,7 +595,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string documentType: typed.document_type, }) - const ccAddress = settings.email ?? null const emailData = { invoice: renderableInvoice, customer, company: settings } const subject = generateInvoiceEmailSubject(emailData) const html = generateInvoiceEmailHtml(emailData) @@ -471,8 +608,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string userId: ctx.userId, invoiceId, deliveryId, - to: customer.email, - cc: ccAddress ?? undefined, + to: recipients.to, + cc: recipients.cc, + bcc: recipients.bcc, subject, html, text, @@ -658,7 +796,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string companyId: ctx.companyId, userId: ctx.userId, invoiceNumber: finalInvoiceNumber, - sentTo: customer.email, + recipientCounts: { + to: recipients.to.length, + cc: recipients.cc.length, + }, journalEntryId, hadWarnings: warnings.length > 0, }) @@ -671,11 +812,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string total: typed.total, message_id: result.messageId ?? null, sent_to: customer.email, - cc: ccAddress, + cc: recipients.cc[0] ?? null, + cc_addresses: recipients.cc, journal_entry_id: journalEntryId, ...(warnings.length > 0 ? { warnings } : {}), }, - { requestId: ctx.requestId }, + { + requestId: ctx.requestId, + headers: { 'Cache-Control': 'private, no-store' }, + }, ) }, { requireIdempotencyKey: true }, diff --git a/components/invoices/SendInvoiceDialog.tsx b/components/invoices/SendInvoiceDialog.tsx index 89295228..a3a652eb 100644 --- a/components/invoices/SendInvoiceDialog.tsx +++ b/components/invoices/SendInvoiceDialog.tsx @@ -31,6 +31,13 @@ import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' +import { + EMAIL_PATTERN, + exceedsInvoiceEmailRecipientLimit, + MAX_INVOICE_EMAIL_RECIPIENTS, + parseInvoiceRecipientText, + resolveInvoiceEmailRecipients, +} from '@/lib/invoices/email-recipients' interface InvoiceWithRelations extends Invoice { customer: Customer @@ -55,7 +62,8 @@ export default function SendInvoiceDialog({ }: SendInvoiceDialogProps) { const { toast } = useToast() const supabase = createClient() - const { company, isSandbox } = useCompany() + const { company, role, isSandbox } = useCompany() + const canCustomizeRecipients = role === 'owner' || role === 'admin' const canEmail = useCapability(CAPABILITY.email_send) const t = useTranslations('invoice_send_dialog') const locale = useLocale() as 'sv' | 'en' @@ -72,6 +80,10 @@ export default function SendInvoiceDialog({ const [catalog, setCatalog] = useState([]) const [editLines, setEditLines] = useState([]) const [hasEdited, setHasEdited] = useState(false) + const [fixedCc, setFixedCc] = useState([]) + const [fixedBcc, setFixedBcc] = useState([]) + const [additionalCcText, setAdditionalCcText] = useState('') + const [additionalBccText, setAdditionalBccText] = useState('') const accountNameByNumber = useMemo(() => { const names = new Map(catalog.map((account) => [account.account_number, account.account_name])) for (const account of accounts) names.set(account.account_number, account.account_name) @@ -92,6 +104,8 @@ export default function SendInvoiceDialog({ useEffect(() => { if (!open) { setIsInitialized(false) + setAdditionalCcText('') + setAdditionalBccText('') return } @@ -101,10 +115,10 @@ export default function SendInvoiceDialog({ try { if (!company?.id) throw new Error(t('no_active_company')) - const [settingsResult, periodResult, originalResult] = await Promise.all([ + const [settingsResult, periodResult, originalResult, authResult] = await Promise.all([ supabase .from('company_settings') - .select('accounting_method, entity_type, defer_invoice_booking') + .select('accounting_method, entity_type, defer_invoice_booking, email, invoice_email_cc_addresses, invoice_email_bcc_addresses') .eq('company_id', company.id) .maybeSingle(), supabase @@ -122,11 +136,13 @@ export default function SendInvoiceDialog({ .eq('company_id', company.id) .maybeSingle() : Promise.resolve({ data: null, error: null }), + supabase.auth.getUser(), ]) if (settingsResult.error) throw new Error(t('company_settings_failed')) if (periodResult.error) throw new Error(t('fiscal_period_failed')) if (originalResult.error) throw new Error(t('original_invoice_failed')) + if (authResult.error || !authResult.data.user) throw new Error(t('load_failed_title')) if (cancelled) return @@ -157,6 +173,16 @@ export default function SendInvoiceDialog({ setAccounts(fetchedAccounts) setCatalog(fetchedCatalog) setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma') + const legacyCc = settingsResult.data?.email || authResult.data.user.email + setFixedCc( + settingsResult.data?.invoice_email_cc_addresses + ?? (legacyCc ? [legacyCc] : []), + ) + setFixedBcc( + canCustomizeRecipients + ? settingsResult.data?.invoice_email_bcc_addresses ?? [] + : [], + ) setPeriodName(periodResult.data?.name || '') setDeferBooking(!!settingsResult.data?.defer_invoice_booking) setShouldBookOnIssue(bookOnIssue) @@ -174,7 +200,7 @@ export default function SendInvoiceDialog({ init() return () => { cancelled = true } - }, [open, invoice.id, invoice.invoice_date, company?.id]) + }, [open, invoice.id, invoice.invoice_date, company?.id, canCustomizeRecipients]) const proposedLines = useMemo(() => { if (!isInitialized || !shouldBookOnIssue) return [] @@ -199,6 +225,29 @@ export default function SendInvoiceDialog({ }) }, [isInitialized, shouldBookOnIssue, entityType, invoice]) + const additionalCc = useMemo( + () => parseInvoiceRecipientText(additionalCcText), + [additionalCcText], + ) + const additionalBcc = useMemo( + () => parseInvoiceRecipientText(additionalBccText), + [additionalBccText], + ) + const invalidAdditionalRecipient = [...additionalCc, ...additionalBcc] + .find((address) => !EMAIL_PATTERN.test(address)) + const resolvedRecipients = resolveInvoiceEmailRecipients({ + to: invoice.customer.email ?? '', + configuredCc: fixedCc, + configuredBcc: fixedBcc, + additionalCc, + additionalBcc, + }) + const recipientError = invalidAdditionalRecipient + ? t('recipient_invalid', { address: invalidAdditionalRecipient }) + : exceedsInvoiceEmailRecipientLimit(resolvedRecipients) + ? t('recipient_too_many', { count: MAX_INVOICE_EMAIL_RECIPIENTS }) + : null + // Seed the editable grid from the proposal once per open; edits must not be // clobbered by re-renders, so proposedLines is deliberately not a dependency. useEffect(() => { @@ -269,6 +318,7 @@ export default function SendInvoiceDialog({ const handleConfirm = async () => { if (editable && (!isBalanced || hasOrphanAmounts)) return + if (mode === 'email' && recipientError) return setIsSubmitting(true) try { @@ -294,12 +344,23 @@ export default function SendInvoiceDialog({ })) : undefined + const payload = { + ...(apiLines ? { lines: apiLines } : {}), + ...(mode === 'email' && canCustomizeRecipients && additionalCc.length > 0 + ? { additional_cc: additionalCc } + : {}), + ...(mode === 'email' && canCustomizeRecipients && additionalBcc.length > 0 + ? { additional_bcc: additionalBcc } + : {}), + } + const hasPayload = Object.keys(payload).length > 0 + const response = await fetch(url, { method: 'POST', - ...(apiLines + ...(hasPayload ? { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lines: apiLines }), + body: JSON.stringify(payload), } : {}), }) @@ -424,6 +485,56 @@ export default function SendInvoiceDialog({ eller använd «Markera som skickad». )} + {mode === 'email' && ( +
+
+

+ {t('recipient_to_label')}:{' '} + {invoice.customer.email} +

+

+ {t('recipient_fixed_cc_label')}:{' '} + {fixedCc.length > 0 ? fixedCc.join(', ') : t('recipient_none')} +

+ {canCustomizeRecipients && ( +

+ {t('recipient_fixed_bcc_label')}:{' '} + {fixedBcc.length > 0 ? fixedBcc.join(', ') : t('recipient_none')} +

+ )} +
+ {canCustomizeRecipients && ( + <> +
+
+ + setAdditionalCcText(event.target.value)} + placeholder={t('recipient_additional_placeholder')} + aria-invalid={!!recipientError} + /> +
+
+ + setAdditionalBccText(event.target.value)} + placeholder={t('recipient_additional_placeholder')} + aria-invalid={!!recipientError} + /> +
+
+

{t('recipient_additional_hint')}

+ {recipientError && ( +

{recipientError}

+ )} + + )} +
+ )} {showJournalPreview && editable ? ( <>

@@ -629,7 +740,7 @@ export default function SendInvoiceDialog({