Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
+39 -3
View File
@@ -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.
+77 -4
View File
@@ -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
+31 -3
View File
@@ -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
@@ -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`.
+27
View File
@@ -283,3 +283,30 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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' } })
@@ -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,
@@ -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' },
)
})
})
+7 -13
View File
@@ -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',
@@ -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,
})
})
})
+38 -41
View File
@@ -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<InvoiceDeliverySummaryRow, 'to_addresses' | 'cc_addresses'> {
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
}
@@ -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',
+35 -17
View File
@@ -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 },
)
@@ -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')
})
})
+38 -6
View File
@@ -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 }
)
}
},
@@ -58,6 +58,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
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 })
+125 -14
View File
@@ -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 },
)
@@ -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')
})
})
+68 -20
View File
@@ -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 }
)
}
})
@@ -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')
})
})
+99 -12
View File
@@ -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 },
)
}
})
+112
View File
@@ -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',
+35 -1
View File
@@ -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,
@@ -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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockCreateJournalEntry = mockedCreateEntry as ReturnType<typeof vi.fn>
const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
@@ -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,
},
}),
)
@@ -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'
@@ -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({
@@ -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,
},
})
@@ -68,6 +68,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
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<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockEnsureInvoiceNumber = mockedEnsureInvoiceNumber as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
@@ -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()
})
@@ -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 },
+118 -7
View File
@@ -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<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false)
const [fixedCc, setFixedCc] = useState<string[]>([])
const [fixedBcc, setFixedBcc] = useState<string[]>([])
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 &laquo;Markera som skickad&raquo;.
</div>
)}
{mode === 'email' && (
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">{t('recipient_to_label')}:</span>{' '}
{invoice.customer.email}
</p>
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('recipient_fixed_cc_label')}:</span>{' '}
{fixedCc.length > 0 ? fixedCc.join(', ') : t('recipient_none')}
</p>
{canCustomizeRecipients && (
<p className="text-muted-foreground">
<span className="font-medium text-foreground">{t('recipient_fixed_bcc_label')}:</span>{' '}
{fixedBcc.length > 0 ? fixedBcc.join(', ') : t('recipient_none')}
</p>
)}
</div>
{canCustomizeRecipients && (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="invoice-additional-cc">{t('recipient_additional_cc_label')}</Label>
<Input
id="invoice-additional-cc"
value={additionalCcText}
onChange={(event) => setAdditionalCcText(event.target.value)}
placeholder={t('recipient_additional_placeholder')}
aria-invalid={!!recipientError}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="invoice-additional-bcc">{t('recipient_additional_bcc_label')}</Label>
<Input
id="invoice-additional-bcc"
value={additionalBccText}
onChange={(event) => setAdditionalBccText(event.target.value)}
placeholder={t('recipient_additional_placeholder')}
aria-invalid={!!recipientError}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('recipient_additional_hint')}</p>
{recipientError && (
<p className="text-sm text-destructive" role="alert">{recipientError}</p>
)}
</>
)}
</div>
)}
{showJournalPreview && editable ? (
<>
<p className="text-sm text-muted-foreground">
@@ -629,7 +740,7 @@ export default function SendInvoiceDialog({
</Button>
<Button
onClick={handleConfirm}
disabled={isSubmitting || !isInitialized || (editable && (!isBalanced || hasOrphanAmounts)) || (mode === 'email' && (isSandbox || !canEmail))}
disabled={isSubmitting || !isInitialized || (editable && (!isBalanced || hasOrphanAmounts)) || (mode === 'email' && (isSandbox || !canEmail || !!recipientError))}
className="w-full sm:w-auto min-h-11"
title={
mode === 'email' && isSandbox
@@ -0,0 +1,150 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import {
EMAIL_PATTERN,
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
parseInvoiceRecipientText,
} from '@/lib/invoices/email-recipients'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
interface InvoiceEmailRecipientsSettingsProps {
settings: CompanySettings
onUpdate: (updates: Partial<CompanySettings>) => void
}
function listText(addresses: readonly string[]): string {
return addresses.join('\n')
}
export function InvoiceEmailRecipientsSettings({
settings,
onUpdate,
}: InvoiceEmailRecipientsSettingsProps) {
const t = useTranslations('settings_invoice_email_recipients')
const { toast } = useToast()
const { role } = useCompany()
const effectiveCc = settings.invoice_email_cc_addresses ?? (
settings.email ? [settings.email] : []
)
const effectiveBcc = settings.invoice_email_bcc_addresses ?? []
const serverCcText = listText(effectiveCc)
const serverBccText = listText(effectiveBcc)
const [ccText, setCcText] = useState(serverCcText)
const [bccText, setBccText] = useState(serverBccText)
const [isSaving, setIsSaving] = useState(false)
const previousServerText = useRef({ cc: serverCcText, bcc: serverBccText })
useEffect(() => {
const previous = previousServerText.current
setCcText((current) => current === previous.cc ? serverCcText : current)
setBccText((current) => current === previous.bcc ? serverBccText : current)
previousServerText.current = { cc: serverCcText, bcc: serverBccText }
}, [serverBccText, serverCcText])
if (role !== 'owner' && role !== 'admin') return null
async function save() {
const cc = parseInvoiceRecipientText(ccText)
const bcc = parseInvoiceRecipientText(bccText)
const invalid = [...cc, ...bcc].find((address) => !EMAIL_PATTERN.test(address))
if (invalid) {
toast({
title: t('invalid_title'),
description: t('invalid_description', { address: invalid }),
variant: 'destructive',
})
return
}
if (cc.length + bcc.length > MAX_INVOICE_EMAIL_COPY_RECIPIENTS) {
toast({
title: t('too_many_title'),
description: t('too_many_description', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }),
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invoice_email_cc_addresses: cc,
invoice_email_bcc_addresses: bcc,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(typeof result.error === 'string' ? result.error : t('save_failed'))
}
onUpdate({
invoice_email_cc_addresses: cc,
invoice_email_bcc_addresses: bcc,
})
setCcText(listText(cc))
setBccText(listText(bcc))
toast({ title: t('saved_title'), description: t('saved_description') })
} catch (error) {
toast({
title: t('save_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('save_failed'),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
return (
<section className="space-y-4">
<div className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="invoice-email-cc">{t('cc_label')}</Label>
<Textarea
id="invoice-email-cc"
value={ccText}
onChange={(event) => setCcText(event.target.value)}
placeholder={t('cc_placeholder')}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t('cc_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice-email-bcc">{t('bcc_label')}</Label>
<Textarea
id="invoice-email-bcc"
value={bccText}
onChange={(event) => setBccText(event.target.value)}
placeholder={t('bcc_placeholder')}
rows={3}
/>
<p className="text-xs text-muted-foreground">{t('bcc_hint')}</p>
</div>
</div>
<div className="flex justify-end">
<Button type="button" onClick={save} disabled={isSaving}>
{isSaving ? t('saving') : t('save')}
</Button>
</div>
</section>
)
}
@@ -0,0 +1,450 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import { validateBankgiroNumber, validatePlusgiroNumber } from '@/lib/bankgiro/luhn'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import {
INVOICE_PAYMENT_ACCOUNT_CURRENCIES,
legacySekInvoicePaymentAccount,
normalizeInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { isValidSwish, normaliseSwish } from '@/lib/payments/swish'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type {
CompanySettings,
Currency,
InvoicePaymentAccount,
} from '@/types'
interface InvoicePaymentAccountsSettingsProps {
settings: CompanySettings
onUpdate: (updates: Partial<CompanySettings>) => void
}
const EMPTY_ACCOUNT: InvoicePaymentAccount = {
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
}
function initialAccounts(
paymentAccounts: CompanySettings['invoice_payment_accounts'],
legacySekAccount: InvoicePaymentAccount,
): Partial<Record<Currency, InvoicePaymentAccount>> {
const configured = Object.fromEntries(
Object.entries(paymentAccounts ?? {}).map(([currency, account]) => [
currency,
normalizeInvoicePaymentAccount(account),
]),
) as Partial<Record<Currency, InvoicePaymentAccount>>
if (!configured.SEK) configured.SEK = legacySekAccount
return configured
}
function value(account: InvoicePaymentAccount, field: keyof InvoicePaymentAccount): string {
return account[field] ?? ''
}
function accountsKey(accounts: Partial<Record<Currency, InvoicePaymentAccount>>): string {
return JSON.stringify(INVOICE_PAYMENT_ACCOUNT_CURRENCIES.map((currency) => [
currency,
accounts[currency] ? normalizeInvoicePaymentAccount(accounts[currency]) : null,
]))
}
export function InvoicePaymentAccountsSettings({
settings,
onUpdate,
}: InvoicePaymentAccountsSettingsProps) {
const t = useTranslations('settings_invoice_payment_accounts')
const { toast } = useToast()
const { role } = useCompany()
const legacySekAccount = useMemo(
() => legacySekInvoicePaymentAccount({
bank_name: settings.bank_name,
clearing_number: settings.clearing_number,
account_number: settings.account_number,
bankgiro: settings.bankgiro,
plusgiro: settings.plusgiro,
swish: settings.swish,
iban: settings.iban,
bic: settings.bic,
}),
[
settings.bank_name,
settings.clearing_number,
settings.account_number,
settings.bankgiro,
settings.plusgiro,
settings.swish,
settings.iban,
settings.bic,
],
)
const serverAccounts = useMemo(
() => initialAccounts(settings.invoice_payment_accounts, legacySekAccount),
[settings.invoice_payment_accounts, legacySekAccount],
)
const serverAccountsKey = accountsKey(serverAccounts)
const [accounts, setAccounts] = useState(serverAccounts)
const [activeCurrency, setActiveCurrency] = useState<Currency>('SEK')
const [currencyToAdd, setCurrencyToAdd] = useState<Currency | ''>('')
const [isSaving, setIsSaving] = useState(false)
const [hasExternalUpdate, setHasExternalUpdate] = useState(false)
const accountsRef = useRef(accounts)
const previousServerAccountsKey = useRef(serverAccountsKey)
accountsRef.current = accounts
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
useEffect(() => {
const previousKey = previousServerAccountsKey.current
if (serverAccountsKey === previousKey) return
const currentKey = accountsKey(accountsRef.current)
if (currentKey === serverAccountsKey) {
setHasExternalUpdate(false)
} else if (currentKey === previousKey) {
accountsRef.current = serverAccounts
setAccounts(serverAccounts)
setHasExternalUpdate(false)
} else {
setHasExternalUpdate(true)
}
previousServerAccountsKey.current = serverAccountsKey
}, [serverAccounts, serverAccountsKey])
const configuredCurrencies = useMemo(
() => INVOICE_PAYMENT_ACCOUNT_CURRENCIES.filter((currency) => !!accounts[currency]),
[accounts],
)
const availableCurrencies = INVOICE_PAYMENT_ACCOUNT_CURRENCIES.filter(
(currency) => !accounts[currency],
)
const activeAccount = accounts[activeCurrency] ?? EMPTY_ACCOUNT
if (role !== 'owner' && role !== 'admin') return null
function updateField(field: keyof InvoicePaymentAccount, nextValue: string) {
setAccounts((current) => ({
...current,
[activeCurrency]: {
...(current[activeCurrency] ?? EMPTY_ACCOUNT),
[field]: nextValue || null,
},
}))
}
function addCurrency() {
if (!currencyToAdd) return
setAccounts((current) => ({
...current,
[currencyToAdd]: { ...EMPTY_ACCOUNT },
}))
setActiveCurrency(currencyToAdd)
setCurrencyToAdd('')
}
function removeActiveCurrency() {
if (activeCurrency === 'SEK') return
setAccounts((current) => {
const next = { ...current }
delete next[activeCurrency]
return next
})
setActiveCurrency('SEK')
}
function reloadServerAccounts() {
accountsRef.current = serverAccounts
setAccounts(serverAccounts)
if (!serverAccounts[activeCurrency]) setActiveCurrency('SEK')
setHasExternalUpdate(false)
}
function validationError(): string | null {
// An added foreign-currency tab is a real configuration immediately. It
// must have an IBAN before save; the Remove action discards placeholders.
for (const currency of configuredCurrencies) {
const account = normalizeInvoicePaymentAccount(accounts[currency] ?? EMPTY_ACCOUNT)
if (account.clearing_number && !/^\d{4,5}$/.test(account.clearing_number)) {
return t('validation_clearing', { currency })
}
if (account.account_number && !/^\d{6,12}$/.test(account.account_number)) {
return t('validation_account_number', { currency })
}
if (account.bankgiro && !validateBankgiroNumber(account.bankgiro)) {
return t('validation_bankgiro', { currency })
}
if (account.plusgiro && !validatePlusgiroNumber(account.plusgiro)) {
return t('validation_plusgiro', { currency })
}
if (account.swish && !isValidSwish(normaliseSwish(account.swish))) {
return t('validation_swish', { currency })
}
if (account.iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(account.iban)) {
return t('validation_iban', { currency })
}
if (account.bic && !/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(account.bic)) {
return t('validation_bic', { currency })
}
if (currency !== 'SEK' && !account.iban) {
return t('validation_foreign_iban', { currency })
}
}
return null
}
async function save() {
if (hasExternalUpdate) {
toast({
title: t('conflict_title'),
description: t('conflict_description'),
variant: 'destructive',
})
return
}
const error = validationError()
if (error) {
toast({ title: t('validation_title'), description: error, variant: 'destructive' })
return
}
const normalized = Object.fromEntries([
[
'SEK',
normalizeInvoicePaymentAccount(accounts.SEK ?? EMPTY_ACCOUNT),
],
...configuredCurrencies.filter((currency) => currency !== 'SEK').map((currency) => [
currency,
normalizeInvoicePaymentAccount(accounts[currency] ?? EMPTY_ACCOUNT),
]),
]) as Partial<Record<Currency, InvoicePaymentAccount>>
const sek = normalized.SEK!
const updates: Partial<CompanySettings> = {
invoice_payment_accounts: normalized,
// The legacy fields are an exact nullable SEK mirror. Clearing SEK is
// intentional and must not leave stale payment instructions behind.
bank_name: sek.bank_name,
clearing_number: sek.clearing_number,
account_number: sek.account_number,
bankgiro: sek.bankgiro,
plusgiro: sek.plusgiro,
swish: sek.swish,
iban: sek.iban,
bic: sek.bic,
}
setIsSaving(true)
try {
const response = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (!response.ok) {
const result = await response.json()
throw new Error(typeof result.error === 'string' ? result.error : t('save_failed'))
}
accountsRef.current = normalized
setAccounts(normalized)
onUpdate(updates)
toast({ title: t('saved_title'), description: t('saved_description') })
} catch (error) {
toast({
title: t('save_failed_title'),
description: error instanceof Error ? getUserErrorMessage(error) : t('save_failed'),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
return (
<section className="space-y-5">
<div className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('heading')}
</h2>
<p className="text-sm text-muted-foreground">{t('description')}</p>
</div>
{hasExternalUpdate && (
<div
role="alert"
className="flex flex-col gap-3 rounded-lg border border-border bg-muted/40 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="space-y-1">
<p className="text-sm font-medium">{t('conflict_title')}</p>
<p className="text-sm text-muted-foreground">{t('conflict_description')}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={reloadServerAccounts}>
{t('reload_server_values')}
</Button>
</div>
)}
<div className="flex flex-wrap gap-2" aria-label={t('currency_tabs_label')}>
{configuredCurrencies.map((currency) => (
<Button
key={currency}
type="button"
size="sm"
variant={activeCurrency === currency ? 'default' : 'outline'}
onClick={() => setActiveCurrency(currency)}
>
{currency}
</Button>
))}
</div>
{availableCurrencies.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="w-full space-y-2 sm:max-w-52">
<Label>{t('add_currency_label')}</Label>
<Select
value={currencyToAdd}
onValueChange={(next) => setCurrencyToAdd(next as Currency)}
>
<SelectTrigger>
<SelectValue placeholder={t('add_currency_placeholder')} />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((currency) => (
<SelectItem key={currency} value={currency}>{currency}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="outline" onClick={addCurrency} disabled={!currencyToAdd}>
{t('add_currency')}
</Button>
</div>
)}
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-medium">{t('account_heading', { currency: activeCurrency })}</h3>
{activeCurrency !== 'SEK' && (
<p className="text-xs text-muted-foreground">{t('foreign_account_hint')}</p>
)}
</div>
{activeCurrency !== 'SEK' && (
<Button type="button" variant="ghost" size="sm" onClick={removeActiveCurrency}>
{t('remove_currency')}
</Button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>{t('bank_label')}</Label>
<BankNameCombobox
value={value(activeAccount, 'bank_name')}
onChange={(next) => updateField('bank_name', next)}
enableBankingEnabled={hasBankingExtension}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-clearing-${activeCurrency}`}>{t('clearing_label')}</Label>
<Input
id={`payment-clearing-${activeCurrency}`}
inputMode="numeric"
maxLength={5}
value={value(activeAccount, 'clearing_number')}
onChange={(event) => updateField('clearing_number', event.target.value.replace(/\D/g, ''))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-account-${activeCurrency}`}>{t('account_number_label')}</Label>
<Input
id={`payment-account-${activeCurrency}`}
inputMode="numeric"
maxLength={12}
value={value(activeAccount, 'account_number')}
onChange={(event) => updateField('account_number', event.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`payment-bankgiro-${activeCurrency}`}>{t('bankgiro_label')}</Label>
<Input
id={`payment-bankgiro-${activeCurrency}`}
value={value(activeAccount, 'bankgiro')}
onChange={(event) => updateField('bankgiro', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-plusgiro-${activeCurrency}`}>{t('plusgiro_label')}</Label>
<Input
id={`payment-plusgiro-${activeCurrency}`}
value={value(activeAccount, 'plusgiro')}
onChange={(event) => updateField('plusgiro', event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-swish-${activeCurrency}`}>{t('swish_label')}</Label>
<Input
id={`payment-swish-${activeCurrency}`}
value={value(activeAccount, 'swish')}
onChange={(event) => updateField('swish', event.target.value)}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={`payment-iban-${activeCurrency}`}>
{t('iban_label')}{activeCurrency !== 'SEK' ? ` ${t('required_suffix')}` : ''}
</Label>
<Input
id={`payment-iban-${activeCurrency}`}
value={value(activeAccount, 'iban')}
onChange={(event) => updateField('iban', event.target.value.toUpperCase())}
placeholder="SE00 0000 0000 0000 0000 0000"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`payment-bic-${activeCurrency}`}>{t('bic_label')}</Label>
<Input
id={`payment-bic-${activeCurrency}`}
maxLength={11}
value={value(activeAccount, 'bic')}
onChange={(event) => updateField('bic', event.target.value.toUpperCase())}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="button" onClick={save} disabled={isSaving || hasExternalUpdate}>
{isSaving ? t('saving') : t('save')}
</Button>
</div>
</section>
)
}
@@ -1,52 +1,26 @@
'use client'
import { useTranslations } from 'next-intl'
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePaymentLinkSettings } from '@/components/settings/InvoicePaymentLinkSettings'
import { InvoicePaymentAccountsSettings } from '@/components/settings/InvoicePaymentAccountsSettings'
import { InvoiceEmailTextsSettings } from '@/components/settings/InvoiceEmailTextsSettings'
import { InvoiceEmailRecipientsSettings } from '@/components/settings/InvoiceEmailRecipientsSettings'
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { normaliseSwish } from '@/lib/payments/swish'
import { formatPlusgiroNumber } from '@/lib/bankgiro/luhn'
import type { CompanySettings } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export function InvoicingSettingsContent() {
const t = useTranslations('settings_invoicing')
const { settings, isLoading, updateSettings, refetch } = useSettings()
const { toast } = useToast()
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
function handleSave(formData: FormData) {
const bankErrors = validateBankFields(formData)
if (bankErrors.length > 0) {
toast({
title: t('bank_validation_title'),
description: bankErrors.map(e => getUserErrorMessage(e)).join(', '),
variant: 'destructive',
})
return {}
}
const updates: Record<string, unknown> = {
bank_name: formData.get('bank_name') as string,
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
bankgiro: (formData.get('bankgiro') as string) || null,
plusgiro: (formData.get('plusgiro') as string)?.trim()
? formatPlusgiroNumber((formData.get('plusgiro') as string).trim())
: null,
swish: normaliseSwish(formData.get('swish') as string) || null,
iban: (formData.get('iban') as string || '').replace(/\s/g, '').toUpperCase() || null,
bic: (formData.get('bic') as string || '').replace(/\s/g, '').toUpperCase() || null,
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
next_arrival_number: parseInt(formData.get('next_arrival_number') as string) || 1,
@@ -74,11 +48,10 @@ export function InvoicingSettingsContent() {
<InvoicePreviewCard settings={settings} />
</div>
<InvoicePaymentAccountsSettings settings={settings} onUpdate={updateSettings} />
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
<InvoiceSettingsForm settings={settings} />
</SettingsFormWrapper>
{/* Payment link opt-in: saves individually via toggle switch */}
@@ -91,6 +64,11 @@ export function InvoicingSettingsContent() {
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* Fixed invoice email recipients: explicit save */}
<div className="border-t border-border pt-8">
<InvoiceEmailRecipientsSettings settings={settings} onUpdate={updateSettings} />
</div>
{/* Invoice email texts: autosaves on blur */}
<div className="border-t border-border pt-8">
<InvoiceEmailTextsSettings settings={settings} onUpdate={updateSettings} />
@@ -17,6 +17,12 @@ function sanitizeHeaderPart(s: string): string {
return s.replace(/[\r\n<>]/g, '').trim()
}
function optionalAddressList(addresses: string | string[] | undefined): string[] | undefined {
if (!addresses) return undefined
const list = Array.isArray(addresses) ? addresses : [addresses]
return list.length > 0 ? list : undefined
}
let resendClient: Resend | null = null
function getResendClient(): Resend {
@@ -35,7 +41,7 @@ function isResendConfigured(): boolean {
export class ResendEmailService implements EmailService {
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const { to, cc, subject, html, text, replyTo, fromName, attachments } = options
const { to, cc, bcc, subject, html, text, replyTo, fromName, attachments } = options
if (!this.isConfigured()) {
return { success: false, error: 'Email service is not configured' }
@@ -56,7 +62,8 @@ export class ResendEmailService implements EmailService {
const response = await resend.emails.send({
from,
to: Array.isArray(to) ? to : [to],
cc: cc ? (Array.isArray(cc) ? cc : [cc]) : undefined,
cc: optionalAddressList(cc),
bcc: optionalAddressList(bcc),
subject,
html,
text,
+56
View File
@@ -1230,6 +1230,62 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('rejects more than 19 fixed invoice copy recipients in total', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_email_cc_addresses: Array.from(
{ length: 10 },
(_, index) => `copy-${index}@example.test`,
),
invoice_email_bcc_addresses: Array.from(
{ length: 10 },
(_, index) => `archive-${index}@example.test`,
),
})
expect(result.success).toBe(false)
})
it('accepts empty strings when clearing nested invoice payment account fields', () => {
const result = UpdateSettingsSchema.safeParse({
invoice_payment_accounts: {
SEK: {
clearing_number: '',
account_number: '',
bankgiro: '',
plusgiro: '',
iban: '',
bic: '',
},
},
})
expect(result.success).toBe(true)
})
it('accepts null when clearing the legacy SEK bank account mirror', () => {
const result = UpdateSettingsSchema.safeParse({
bank_name: null,
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
})
expect(result.success).toBe(true)
})
it('accepts and normalizes a non-Swedish IBAN in the legacy SEK mirror', () => {
const result = UpdateSettingsSchema.safeParse({
iban: 'gb29 nwbk 6016 1331 9268 19',
})
expect(result.success).toBe(true)
if (result.success) expect(result.data.iban).toBe('GB29NWBK60161331926819')
})
it('accepts a positive next_arrival_number (supplier-invoice start floor)', () => {
const result = UpdateSettingsSchema.safeParse({ next_arrival_number: 248 })
expect(result.success).toBe(true)
+84 -4
View File
@@ -5,6 +5,7 @@ import { isSaneDateString } from '@/lib/utils'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { MAX_INVOICE_EMAIL_COPY_RECIPIENTS } from '@/lib/invoices/email-recipients'
import type { AuditAction } from '@/types'
// ============================================================
@@ -33,6 +34,19 @@ const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactl
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
const invoiceEmailAddress = z
.string()
.trim()
.email('Ange en giltig e-postadress')
.max(254, 'E-postadressen får vara max 254 tecken')
const invoiceEmailAddressList = z
.array(invoiceEmailAddress)
.max(
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
`Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} kopiemottagare är tillåtna`,
)
/** BAS class-3 revenue account: exactly 4 digits starting with 3 (försäljning/intäkt). */
const revenueAccount = z
.string()
@@ -728,6 +742,20 @@ export const MarkInvoiceSentSchema = z.object({
})).min(2).optional(),
})
export const SendInvoiceSchema = MarkInvoiceSentSchema.extend({
additional_cc: invoiceEmailAddressList.optional(),
additional_bcc: invoiceEmailAddressList.optional(),
}).refine(
(data) => (
(data.additional_cc?.length ?? 0) + (data.additional_bcc?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} extra kopiemottagare är tillåtna totalt`,
path: ['additional_cc'],
},
)
// ============================================================
// Customer schemas
// ============================================================
@@ -1499,6 +1527,43 @@ export const InvoiceEmailTextsSchema = z.object({
en: InvoiceEmailTextsLangSchema.optional(),
})
const InvoiceIbanSchema = z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/, 'Ogiltigt IBAN'))
.nullable()
.optional()
.or(z.literal(''))
const InvoicePaymentAccountSchema = z.object({
bank_name: z.string().trim().max(100).nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string().transform(normaliseSwish).pipe(z.string().refine(isValidSwish, 'Ogiltigt Swish-nummer')).nullable().optional(),
iban: InvoiceIbanSchema,
bic: z.string()
.transform((value) => value.replace(/\s/g, '').toUpperCase())
.pipe(z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT'))
.nullable()
.optional()
.or(z.literal('')),
})
const InvoicePaymentAccountsSchema = z
.partialRecord(CurrencySchema, InvoicePaymentAccountSchema)
.superRefine((accounts, ctx) => {
for (const [currency, account] of Object.entries(accounts)) {
if (currency !== 'SEK' && account && !account.iban) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [currency, 'iban'],
message: `IBAN krävs för betalningskonto i ${currency}`,
})
}
}
})
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
@@ -1536,9 +1601,9 @@ export const UpdateSettingsSchema = z.object({
preliminary_tax_monthly: z.number().nullable().optional(),
employer_registered: z.boolean().nullable().optional(),
employer_seasonal: z.boolean().optional(),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')),
bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').nullable().optional(),
clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').nullable().optional().or(z.literal('')),
account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').nullable().optional().or(z.literal('')),
bankgiro: z.string().regex(/^(\d{3,4}-\d{4}|\d{7,8})$/, 'Ogiltigt bankgironummer (7-8 siffror)').nullable().optional().or(z.literal('')),
plusgiro: z.string().regex(/^\d{1,7}-\d{1}$/, 'Ogiltigt plusgironummer').nullable().optional().or(z.literal('')),
swish: z.string()
@@ -1551,8 +1616,11 @@ export const UpdateSettingsSchema = z.object({
)
.nullable()
.optional(),
iban: z.string().regex(/^SE\d{22}$/, 'Ogiltigt IBAN (SE följt av 22 siffror)').nullable().optional().or(z.literal('')),
// Legacy SEK mirror of invoice_payment_accounts.SEK. Use the same general
// IBAN validation because a SEK-denominated account need not be Swedish.
iban: InvoiceIbanSchema,
bic: z.string().regex(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 'Ogiltig BIC/SWIFT (8 eller 11 tecken)').nullable().optional().or(z.literal('')),
invoice_payment_accounts: InvoicePaymentAccountsSchema.optional(),
accounting_method: AccountingMethodSchema.optional(),
// #967: register/send invoices without booking; booking is a separate step.
defer_invoice_booking: z.boolean().optional(),
@@ -1606,6 +1674,8 @@ export const UpdateSettingsSchema = z.object({
// all overrides. Without this entry the generic PUT would silently strip
// the field (the schema is the de-facto column whitelist).
invoice_email_texts: InvoiceEmailTextsSchema.nullable().optional(),
invoice_email_cc_addresses: invoiceEmailAddressList.nullable().optional(),
invoice_email_bcc_addresses: invoiceEmailAddressList.nullable().optional(),
// Invoice branding: colors enforced as #RRGGBB at the DB level too
// (see migration 20260526120200_invoice_branding.sql). The dedicated
// /api/settings/invoicing/branding route is the primary path; these
@@ -1661,6 +1731,16 @@ export const UpdateSettingsSchema = z.object({
// blocks changing this while open vacation-ledger rows exist.
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
}).refine(
(data) => (
(data.invoice_email_cc_addresses?.length ?? 0)
+ (data.invoice_email_bcc_addresses?.length ?? 0)
<= MAX_INVOICE_EMAIL_COPY_RECIPIENTS
),
{
message: `Högst ${MAX_INVOICE_EMAIL_COPY_RECIPIENTS} fasta kopiemottagare är tillåtna totalt`,
path: ['invoice_email_cc_addresses'],
},
).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
+45
View File
@@ -355,6 +355,51 @@ describe('withApiV1: idempotency', () => {
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('hashes a cloned body and leaves the original readable by the handler', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: 'company-1',
scopes: ['invoices:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeSupabaseStub({ company_id: 'company-1', role: 'owner' }))
mockCheckIdempotency.mockResolvedValue(null)
let observedBody: unknown
const handler = withApiV1(
'invoices.create',
async (request, ctx) => {
observedBody = await request.json()
return ok({ ok: true }, { requestId: ctx.requestId })
},
{ requireScope: 'invoices:write' },
)
const requestBody = { customer_id: 'cust-1', additional_cc: ['copy@example.test'] }
const response = await handler(
makeRequest('https://x.test/api/v1/companies/company-1/invoices', {
method: 'POST',
headers: {
Authorization: 'Bearer gnubok_sk_x',
'Idempotency-Key': 'key-body-readable',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
}),
companyParams('company-1'),
)
expect(response.status).toBe(200)
expect(observedBody).toEqual(requestBody)
expect(mockCheckIdempotency).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
'key-body-readable',
expect.any(String),
)
})
})
describe('withApiV1: dry-run', () => {
@@ -26,8 +26,9 @@ vi.mock('@/lib/reports/kassaflodesanalys', () => ({
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
listAssets: vi.fn().mockResolvedValue([]),
}))
const mockFetchAllRows = vi.hoisted(() => vi.fn())
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([]),
fetchAllRows: mockFetchAllRows,
}))
import { buildArsredovisningData } from '../build-data'
@@ -274,6 +275,7 @@ function plantStandardReports() {
beforeEach(() => {
vi.clearAllMocks()
mockFetchAllRows.mockResolvedValue([])
plantStandardReports()
})
@@ -352,6 +354,43 @@ describe('buildArsredovisningData: K3', () => {
})
describe('buildArsredovisningData: K2 byte-equivalence', () => {
it('keeps tax and appropriations in the statutory pre-closing balance', async () => {
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
expect(mockedTrialBalance).toHaveBeenCalledWith(
expect.anything(),
'co1',
'fp1',
{ excludeFinalClosingEntry: true },
)
expect(mockedTrialBalance).not.toHaveBeenCalledWith(
expect.anything(),
'co1',
'fp1',
{ excludeYearEndClosing: true },
)
})
it('reuses the current-period mapping in the multi-year overview', async () => {
mockFetchAllRows.mockResolvedValueOnce([
{
id: 'fp1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
},
])
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
await buildArsredovisningData(supabase, 'co1', 'fp1')
const currentPeriodCalls = mockedTrialBalance.mock.calls.filter((call) => call[2] === 'fp1')
expect(currentPeriodCalls).toHaveLength(2)
})
it('records accounting_framework=k2', async () => {
const supabase = makeSupabase({ accountingFramework: 'k2' })
// @ts-expect-error: chainable mock isn't fully typed as SupabaseClient
@@ -5,7 +5,12 @@ import {
type AnnualReportEligibilityResult,
type AnnualReportProfile,
} from '../compliance-types'
import { validateAnnualReportCompleteness } from '../completeness'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from '../completeness'
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from '../statement-rows'
const eligibility: AnnualReportEligibilityResult = {
k2_eligible: true,
@@ -47,9 +52,16 @@ function report(): ArsredovisningData {
total_equity_liabilities: 100,
total_assets_previous: null,
total_equity_liabilities_previous: null,
assets: [{ label: 'Bank', amount: 100 }],
assets: [{ label: 'Bank', current: 100, previous: null }],
equity_liabilities: [
{ label: 'Eget kapital', current: 100, previous: null },
{ label: 'Årets resultat', current: 20, previous: null },
],
},
resultatrakning: [{ label: 'Nettoomsättning', amount: 100 }],
resultatrakning: [
{ label: 'Nettoomsättning', current: 20, previous: null },
{ label: 'Årets resultat', current: 20, previous: null, is_total: true },
],
noter: [{ number: 1, title: 'Principer', body: 'K2' }],
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: '2026-03-01' }],
warnings: [],
@@ -157,6 +169,115 @@ describe('validateAnnualReportCompleteness', () => {
expect(result.issues.some((issue) => issue.code === 'AR-DIVIDEND-EXCEEDS-EQUITY')).toBe(true)
})
it('blocks a version whose income-statement result differs from equity', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Årets resultat', current: 790_296, previous: null, is_total: true },
]
value.report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 469_542
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH', severity: 'error' }),
]),
)
})
it('compares annual-report results at whole-ore precision', () => {
const value = report()
value.resultatrakning = [
{ label: 'Årets resultat', current: 0.1 + 0.2, previous: null, is_total: true },
]
value.balansrakning.equity_liabilities = [
{ label: 'Årets resultat', current: 0.3, previous: null },
]
value.forvaltningsberattelse.resultatdisposition_amounts.current_year_result = 0.3
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISMATCH' }),
]),
)
})
it('blocks a version when the income statement has no final result row', () => {
const value = input('draft')
value.report.resultatrakning = [
{ label: 'Nettoomsättning', current: 100, previous: null },
]
const result = validateAnnualReportCompleteness(value)
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING', severity: 'error' }),
]),
)
})
it('identifies the statutory result by semantic key instead of its K2 label', () => {
const value = report()
value.resultatrakning = [{
label: 'Årets resultat/förlust',
semantic_key: 'income_statement_result',
current: 20,
previous: null,
is_total: true,
}]
value.balansrakning.equity_liabilities = [{
label: 'Periodens resultat',
semantic_key: 'balance_sheet_current_year_result',
current: 20,
previous: null,
}]
expect(validateStatementIntegrity(value)).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-RESULT-MISSING' }),
]),
)
})
it('detects a line reclassification between PDF and iXBRL with unchanged totals', () => {
const value = input('draft')
const full = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
]
const preClosing = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const balanceRows = buildBrRows(mapping)
value.report.resultatrakning = buildRrRows(mapping)
value.report.balansrakning.assets = balanceRows.assets
value.report.balansrakning.equity_liabilities = balanceRows.equityLiabilities
value.report.balansrakning.total_assets = mapping.totals.tillgangar.current
value.report.balansrakning.total_equity_liabilities =
mapping.totals.egetKapitalSkulder.current
const ixbrl = {
rr: {
...mapping.rr,
Nettoomsattning: { current: 0, previous: null },
OvrigaRorelseintakter: { current: 20, previous: null },
},
br: mapping.br,
totals: mapping.totals,
}
expect(validateStatementIntegrity(value.report, ixbrl as never)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'AR-IXBRL-STATEMENT-MISMATCH' }),
]),
)
})
it('requires a documented prudence assessment for a positive dividend', () => {
const value = input('draft')
value.report.forvaltningsberattelse.resultatdisposition_amounts.proposed_dividend = 50
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { mapTrialBalancesToK2, type TrialBalanceRowLike } from '../../ixbrl/k2-mapper'
import { calculateSoliditet } from '../build-data'
function row(
account: string,
debit: number,
credit: number,
): TrialBalanceRowLike {
return {
account_number: account,
account_name: `Account ${account}`,
closing_debit: debit,
closing_credit: credit,
}
}
describe('calculateSoliditet', () => {
it('returns null when total assets are non-positive', () => {
const mapping = mapTrialBalancesToK2({ full: [], preClosing: [] }, null)
expect(calculateSoliditet(mapping)).toBeNull()
})
it('uses adjusted equity and the sign-reclassified balance-sheet total', () => {
const full = [
row('1630', 0, 22_985),
row('1930', 17_428.36, 0),
row('1940', 749_306.35, 0),
row('2081', 0, 25_000),
row('2099', 0, 469_542.21),
row('2125', 0, 197_574),
row('2512', 0, 123_180),
row('2518', 101_970, 0),
row('2641', 1_387.5, 0),
row('2891', 0, 23_223),
row('2893', 0, 8_588),
]
const mapping = mapTrialBalancesToK2({ full, preClosing: full }, null)
expect(mapping.br['OvrigaFordringarKortfristiga'].current).toBe(1_387)
expect(mapping.br['KassaBankExklRedovisningsmedel'].current).toBe(766_735)
expect(mapping.br['Skatteskulder'].current).toBe(44_195)
expect(mapping.br['OvrigaKortfristigaSkulder'].current).toBe(31_811)
expect(mapping.br['Periodiseringsfonder'].current).toBe(197_574)
expect(mapping.totals.tillgangar.current).toBe(768_122)
expect(mapping.totals.egetKapitalSkulder.current).toBe(768_122)
expect(calculateSoliditet(mapping)).toBe(84.8)
})
})
@@ -65,6 +65,23 @@ function previousPair(): TrialBalancePair {
return { full, preClosing }
}
function reportedAccountsPair(): TrialBalancePair {
const full = [
tbRow('1250', 'Computers', { debit: 50 }),
tbRow('1259', 'Accumulated depreciation', { credit: 10 }),
tbRow('1930', 'Bank', { debit: 75 }),
tbRow('2081', 'Share capital', { credit: 50 }),
tbRow('2099', 'Current-year result', { credit: 90 }),
tbRow('2650', 'VAT settlement account', { debit: 25 }),
]
const preClosing = [
...full.filter((row) => row.account_number !== '2099'),
tbRow('3010', 'Revenue', { credit: 100 }),
tbRow('7833', 'Depreciation of computers', { debit: 10 }),
]
return { full, preClosing }
}
describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
it('no RR or BR label contains a BAS account number', () => {
const mapping = mapTrialBalancesToK2(currentPair(), previousPair())
@@ -79,6 +96,17 @@ describe('buildRrRows / buildBrRows — no kontonummer regression', () => {
})
describe('buildRrRows', () => {
it('renders account 7833 depreciation in the statutory expense post', () => {
const rows = buildRrRows(mapTrialBalancesToK2(reportedAccountsPair(), null))
expect(
rows.find(
(row) =>
row.label === 'Av- och nedskrivningar av materiella och immateriella anläggningstillgångar',
)?.current,
).toBe(-10)
})
it('follows the ÅRL uppställningsform order with posts and subtotals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const labels = buildRrRows(mapping).map((r) => r.label)
@@ -130,6 +158,7 @@ describe('buildRrRows', () => {
const rr = buildRrRows(mapping)
const aretsResultat = rr[rr.length - 1]
expect(aretsResultat.label).toBe('Årets resultat')
expect(aretsResultat.semantic_key).toBe('income_statement_result')
expect(aretsResultat.is_total).toBe(true)
expect(aretsResultat.current).toBe(mapping.totals.aretsResultat.current)
expect(aretsResultat.current).toBe(300_000)
@@ -146,10 +175,25 @@ describe('buildRrRows', () => {
})
describe('buildBrRows', () => {
it('renders a debit on account 2650 under receivables instead of liabilities', () => {
const { assets, equityLiabilities } = buildBrRows(
mapTrialBalancesToK2(reportedAccountsPair(), null),
)
expect(assets.find((row) => row.label === 'Övriga fordringar')?.current).toBe(25)
expect(equityLiabilities.find((row) => row.label === 'Övriga skulder')?.current).toBe(0)
expect(assets.at(-1)?.current).toBe(140)
expect(equityLiabilities.at(-1)?.current).toBe(140)
})
it('renders Kassa och bank as a post and ends both sides on tied totals', () => {
const mapping = mapTrialBalancesToK2(currentPair(), null)
const { assets, equityLiabilities } = buildBrRows(mapping)
expect(
equityLiabilities.find((row) => row.semantic_key === 'balance_sheet_current_year_result'),
).toMatchObject({ label: 'Årets resultat', current: 300_000 })
expect(assets.find((r) => r.label === 'Kassa och bank' && !r.is_heading)?.current).toBe(
600_000,
)
@@ -1,8 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import type { CanonicalAnnualReport } from '../compliance-types'
import { annualReportContentHash, createAnnualReportVersion } from '../version-service'
import {
annualReportContentHash,
createAnnualReportVersion,
} from '../version-service'
import { mapTrialBalancesToK2 } from '../../ixbrl/k2-mapper'
import { buildBrRows, buildRrRows } from '../statement-rows'
function model(signedAt: string | null): CanonicalAnnualReport {
const full = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '2099', account_name: 'Current result', closing_debit: 0, closing_credit: 20 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 20, closing_credit: 20 },
]
const preClosing = [
{ account_number: '1930', account_name: 'Bank', closing_debit: 100, closing_credit: 0 },
{ account_number: '2081', account_name: 'Share capital', closing_debit: 0, closing_credit: 80 },
{ account_number: '3010', account_name: 'Revenue', closing_debit: 0, closing_credit: 20 },
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const balanceRows = buildBrRows(mapping)
return {
schema_version: '1.0',
generated_at: '2026-07-21T10:00:00Z',
@@ -12,6 +30,16 @@ function model(signedAt: string | null): CanonicalAnnualReport {
report: {
accounting_framework: 'k2',
signatures: [{ role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: signedAt }],
resultatrakning: buildRrRows(mapping),
balansrakning: {
assets: balanceRows.assets,
equity_liabilities: balanceRows.equityLiabilities,
total_assets: 100,
total_equity_liabilities: 100,
},
forvaltningsberattelse: {
resultatdisposition_amounts: { current_year_result: 20 },
},
},
profile: { reporting_currency: 'SEK' },
disclosures: {},
@@ -22,6 +50,9 @@ function model(signedAt: string | null): CanonicalAnnualReport {
validation: { ok: true },
ixbrl: {
entryPointId: 'k2-ab-risbs-2024-09-12',
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
underskrifter: {
dateringsdatum: signedAt,
signers: [
@@ -76,4 +107,46 @@ describe('annualReportContentHash', () => {
}),
)
})
it('refuses to snapshot inconsistent financial statements', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.report.resultatrakning.find(
(row) => row.label === 'Årets resultat',
)!.current = 21
inconsistent.validation = { ...inconsistent.validation, ok: true, issues: [] }
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
it('refuses a PDF and iXBRL line reclassification with unchanged totals', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.ixbrl!.rr = {
...inconsistent.ixbrl!.rr,
Nettoomsattning: { current: 0, previous: null },
OvrigaRorelseintakter: { current: 20, previous: null },
}
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
it('refuses a mutated visible balance-sheet result row', async () => {
const rpc = vi.fn()
const inconsistent = model(null)
inconsistent.report.balansrakning.equity_liabilities.find(
(row) => row.label === 'Årets resultat',
)!.current = 19
await expect(
createAnnualReportVersion({ rpc } as never, 'user-1', inconsistent, false),
).rejects.toThrow('inconsistent financial statements')
expect(rpc).not.toHaveBeenCalled()
})
})
+32 -44
View File
@@ -1,5 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
@@ -85,7 +84,7 @@ export async function buildArsredovisningData(
.range(from, to),
),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
// Load persisted narrative overrides: replaces the URL-query-param
// carry from earlier phases. Caller-supplied overrides (passed in via
// the second arg) still win, so the API can layer per-request edits on
@@ -134,8 +133,11 @@ export async function buildArsredovisningData(
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prevPeriodRow.id),
// Comparative RR figures need the same statutory view as the current
// year: keep booked depreciation, appropriations, and tax, excluding
// only the linked final result-closing entry.
generateTrialBalance(supabase, companyId, prevPeriodRow.id, {
excludeYearEndClosing: true,
excludeFinalClosingEntry: true,
}),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
@@ -169,7 +171,7 @@ export async function buildArsredovisningData(
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
accountingFramework,
mapping,
)
const egen_kapital_changes = buildEquityChanges(mapping)
@@ -402,12 +404,21 @@ interface PeriodRow {
period_end: string
}
export function calculateSoliditet(mapping: K2MappingResult): number | null {
const totalAssets = mapping.totals.tillgangar.current
if (totalAssets <= 0) return null
const adjustedEquity =
mapping.totals.egetKapital.current +
mapping.totals.obeskattadeReserver.current * (1 - LATENT_TAX_DEFAULT_RATE)
return Math.round((adjustedEquity / totalAssets) * 1000) / 10
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
accountingFramework: AccountingFramework,
currentMapping: K2MappingResult,
): Promise<FlerarsoversiktRow[]> {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
@@ -418,46 +429,23 @@ async function buildFlerarsoversikt(
const rows: FlerarsoversiktRow[] = []
for (const p of slice) {
try {
const [is, tb] = await Promise.all([
generateIncomeStatement(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id),
])
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
const netRevenue = is.total_revenue
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
const totalAssets = tb.rows
.filter((r) => r.account_class === 1)
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
const eqLiab = tb.rows
.filter((r) => r.account_class === 2)
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
// Soliditet differs by framework:
// K2 (ÅRL / BFNAR 2016:10): 20xx only. 21xx (periodiseringsfonder,
// överavskrivningar) are obeskattade reserver: partially deferred
// tax, not equity. Including 21xx would inflate soliditet for any AB
// that posts dispositions.
//
// K3 (BFNAR 2012:1) splits 21xx into 79,4 % equity + 20,6 % latent
// skatteskuld. Account 2240 holds the latent tax liability and is
// already classified as a liability via class 2 / account_group 22,
// so the soliditet add-on is just the equity portion of 21xx. (We
// do NOT double-count 2240 here: the trial balance row for 2240
// already lives in eqLiab as a liability.)
const baseEquity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
let equity = baseEquity
if (accountingFramework === 'k3') {
const obeskattadeReserver = tb.rows
.filter((r) => r.account_number.startsWith('21'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
equity += obeskattadeReserver * (1 - LATENT_TAX_DEFAULT_RATE)
let mapping = currentMapping
if (p.id !== currentPeriodId) {
const [tbFull, tbPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id, { excludeFinalClosingEntry: true }),
])
mapping = mapTrialBalancesToK2(
{ full: tbFull.rows, preClosing: tbPreClosing.rows },
null,
)
}
const soliditet =
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
// Avoid the unused-variable warning while leaving eqLiab computed for
// future "Skulder" column expansion.
void eqLiab
const netRevenue = mapping.rr['Nettoomsattning']?.current ?? 0
const resultAfterFinancial = mapping.totals.resultatEfterFinansiellaPoster.current
// K2 flerårsöversikt defines soliditet as adjusted equity divided by
// total assets. Adjusted equity includes the equity portion of untaxed
// reserves even though those reserves remain a separate BR section.
const soliditet = calculateSoliditet(mapping)
rows.push({
year: p.name,
net_revenue: Math.round(netRevenue),
+118 -15
View File
@@ -1,4 +1,6 @@
import type { ArsredovisningData } from './types'
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
import { buildBrRows, buildRrRows } from './statement-rows'
import type {
AnnualReportComplianceIssue,
AnnualReportDisclosureState,
@@ -39,6 +41,121 @@ function push(
issues.push({ code, severity, section, message, remediation })
}
function statementRowsEqual(
left: ArsredovisningData['resultatrakning'],
right: ArsredovisningData['resultatrakning'],
): boolean {
return left.length === right.length && left.every((row, index) => {
const other = right[index]
return (
row.label === other.label &&
row.semantic_key === other.semantic_key &&
row.current === other.current &&
row.previous === other.previous &&
Boolean(row.is_total) === Boolean(other.is_total) &&
Boolean(row.is_heading) === Boolean(other.is_heading) &&
(row.indent ?? 0) === (other.indent ?? 0)
)
})
}
export function validateStatementIntegrity(
report: ArsredovisningData,
ixbrl: IxbrlArsredovisningInput | null = null,
): AnnualReportComplianceIssue[] {
const issues: AnnualReportComplianceIssue[] = []
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
const incomeStatementResult = (
report.resultatrakning.find(
(row) => row.semantic_key === 'income_statement_result' && row.current !== null,
)
?? report.resultatrakning.find(
(row) => row.label === 'Årets resultat' && row.current !== null,
)
)?.current ?? undefined
const visibleBalanceSheetResult = (
report.balansrakning.equity_liabilities.find(
(row) => row.semantic_key === 'balance_sheet_current_year_result' && row.current !== null,
)
?? report.balansrakning.equity_liabilities.find(
(row) => row.label === 'Årets resultat' && row.current !== null,
)
)?.current ?? undefined
const dispositionResult =
report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result
if (incomeStatementResult === undefined || visibleBalanceSheetResult === undefined) {
push(
issues,
'AR-RESULT-MISSING',
'error',
'statements',
'Resultat- eller balansräkningen saknar raden Årets resultat.',
)
} else if (
Math.round(incomeStatementResult * 100) !== Math.round(visibleBalanceSheetResult * 100) ||
Math.round(incomeStatementResult * 100) !== Math.round(dispositionResult * 100)
) {
push(
issues,
'AR-RESULT-MISMATCH',
'error',
'statements',
'Årets resultat i resultaträkningen stämmer inte med årets resultat i balansräkningen.',
'Kontrollera att årsredovisningen innehåller bokslutsdispositioner och skatt före resultatstängningen.',
)
}
if (
report.resultatrakning.length === 0 ||
report.balansrakning.assets.length === 0 ||
report.balansrakning.equity_liabilities.length === 0
) {
push(
issues,
'AR-STATEMENTS-EMPTY',
'error',
'statements',
'Resultat- eller balansräkningen saknar rader.',
)
}
if (ixbrl) {
const ixbrlMapping = { rr: ixbrl.rr, br: ixbrl.br, totals: ixbrl.totals }
const ixbrlIncomeRows = buildRrRows(ixbrlMapping)
const ixbrlBalanceRows = buildBrRows(ixbrlMapping)
if (
!statementRowsEqual(report.resultatrakning, ixbrlIncomeRows) ||
!statementRowsEqual(report.balansrakning.assets, ixbrlBalanceRows.assets) ||
!statementRowsEqual(
report.balansrakning.equity_liabilities,
ixbrlBalanceRows.equityLiabilities,
)
) {
push(
issues,
'AR-IXBRL-STATEMENT-MISMATCH',
'error',
'statements',
'Beloppen i PDF-underlaget och iXBRL-underlaget stämmer inte överens.',
'Skapa om årsredovisningen från ett oförändrat bokslut.',
)
}
}
return issues
}
export interface ValidateAnnualReportInput {
report: ArsredovisningData
profile: AnnualReportProfile
@@ -154,18 +271,7 @@ export function validateAnnualReportCompleteness(
)
}
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
issues.push(...validateStatementIntegrity(report))
if (
report.previous_period &&
(report.balansrakning.total_assets_previous === null ||
@@ -179,9 +285,6 @@ export function validateAnnualReportCompleteness(
'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
)
}
if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
}
if (report.noter.length === 0) {
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
}
+17 -2
View File
@@ -3,7 +3,10 @@ import { buildArsredovisningData } from './build-data'
import { listSignatureRequests } from './signature-service'
import { getAnnualReportProfile } from './profile-service'
import { evaluateAnnualReportEligibility } from './eligibility'
import { validateAnnualReportCompleteness } from './completeness'
import {
validateAnnualReportCompleteness,
validateStatementIntegrity,
} from './completeness'
import {
ANNUAL_REPORT_SCHEMA_VERSION,
type AnnualReportDisclosureState,
@@ -113,7 +116,7 @@ export async function buildCanonicalAnnualReport(
metrics,
})
const disclosures = disclosureState(report)
const validation = validateAnnualReportCompleteness({
let validation = validateAnnualReportCompleteness({
report,
profile,
disclosures,
@@ -133,6 +136,18 @@ export async function buildCanonicalAnnualReport(
})
}
const crossDocumentIssues = validateStatementIntegrity(report, ixbrl).filter(
(issue) => issue.code === 'AR-IXBRL-STATEMENT-MISMATCH',
)
if (crossDocumentIssues.length > 0) {
validation = {
...validation,
ok: false,
error_count: validation.error_count + crossDocumentIssues.length,
issues: [...validation.issues, ...crossDocumentIssues],
}
}
return {
schema_version: ANNUAL_REPORT_SCHEMA_VERSION,
generated_at: options.generatedAt ?? new Date().toISOString(),
+14 -5
View File
@@ -21,6 +21,7 @@ import type { K2MappingResult } from '@/lib/bokslut/ixbrl/k2-mapper'
import type { StatementRow } from './types'
const ZERO: ConceptAmount = { current: 0, previous: null }
type StatementMapping = Pick<K2MappingResult, 'rr' | 'br' | 'totals'>
function hasValue(amount: ConceptAmount): boolean {
return amount.current !== 0 || (amount.previous ?? 0) !== 0
@@ -28,6 +29,7 @@ function hasValue(amount: ConceptAmount): boolean {
interface RowOptions {
indent?: number
semantic_key?: StatementRow['semantic_key']
/** Presentational minus — show cost posts as negative. */
displayMinus?: boolean
/** Emit the row even when zero in both years (statutory always-visible posts). */
@@ -64,6 +66,7 @@ class RowBuilder {
label,
current: sign * amount.current,
previous: this.hasPrevious ? sign * (amount.previous ?? 0) : null,
...(opts.semantic_key ? { semantic_key: opts.semantic_key } : {}),
...(isTotal ? { is_total: true } : {}),
...(opts.indent ? { indent: opts.indent } : {}),
}
@@ -73,7 +76,7 @@ class RowBuilder {
/** The mapper leaves `previous` null on every concept when the company has
* no previous fiscal year; any concept with a number means a jämförelseår
* exists. */
function mappingHasPrevious(mapping: K2MappingResult): boolean {
function mappingHasPrevious(mapping: StatementMapping): boolean {
return mapping.totals.tillgangar.previous !== null
}
@@ -81,7 +84,7 @@ function mappingHasPrevious(mapping: K2MappingResult): boolean {
* Resultaträkning kostnadsslagsindelad per ÅRL bilaga 2 / K2 risbs, in
* uppställningsform order.
*/
export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
export function buildRrRows(mapping: StatementMapping): StatementRow[] {
const { rr, totals } = mapping
const b = new RowBuilder(mappingHasPrevious(mapping))
@@ -171,7 +174,9 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
b.heading('Skatter')
b.post('Skatt på årets resultat', rr['SkattAretsResultat'], { indent: 1, displayMinus: true })
b.post('Övriga skatter', rr['OvrigaSkatter'], { indent: 1, displayMinus: true })
b.total('Årets resultat', totals.aretsResultat)
b.total('Årets resultat', totals.aretsResultat, {
semantic_key: 'income_statement_result',
})
return b.rows
}
@@ -182,7 +187,7 @@ export function buildRrRows(mapping: K2MappingResult): StatementRow[] {
* kortfristiga fordringar, kassa och bank, eget kapital and kortfristiga
* skulder always render.
*/
export function buildBrRows(mapping: K2MappingResult): {
export function buildBrRows(mapping: StatementMapping): {
assets: StatementRow[]
equityLiabilities: StatementRow[]
} {
@@ -356,7 +361,11 @@ export function buildBrRows(mapping: K2MappingResult): {
e.heading('Fritt eget kapital', 1)
e.post('Överkursfond', br['Overkursfond'], { indent: 2 })
e.post('Balanserat resultat', br['BalanseratResultat'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], { indent: 2, alwaysShow: true })
e.post('Årets resultat', br['AretsResultatEgetKapital'], {
indent: 2,
alwaysShow: true,
semantic_key: 'balance_sheet_current_year_result',
})
e.total('Summa fritt eget kapital', totals.frittEgetKapital, { indent: 1 })
e.total('Summa eget kapital', totals.egetKapital)
if (hasValue(totals.obeskattadeReserver)) {
+4
View File
@@ -38,6 +38,9 @@ export interface NoteEntry {
*/
export interface StatementRow {
label: string
/** Stable integrity key for rows whose legal meaning must not depend on the
* localized presentation label. */
semantic_key?: 'income_statement_result' | 'balance_sheet_current_year_result'
/** Whole-SEK amount for the current year; null on heading rows. */
current: number | null
/** Previous-year amount (jämförelseår, ÅRL 3:5 §); null on heading rows
@@ -95,6 +98,7 @@ export interface ArsredovisningData {
resultatdisposition_amounts: {
retained_earnings: number
share_premium_reserve: number
/** Server-derived from the statutory statement mapping, never narrative input. */
current_year_result: number
total: number
proposed_dividend: number
@@ -6,6 +6,11 @@ import type {
CanonicalAnnualReport,
} from './compliance-types'
import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
import { validateStatementIntegrity } from './completeness'
export function hasStatementIntegrityErrors(model: CanonicalAnnualReport): boolean {
return validateStatementIntegrity(model.report, model.ixbrl).length > 0
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue)
@@ -143,6 +148,9 @@ export async function createAnnualReportVersion(
model: CanonicalAnnualReport,
finalize: boolean,
): Promise<AnnualReportVersionSummary> {
if (hasStatementIntegrityErrors(model)) {
throw new Error('Annual report has inconsistent financial statements and cannot be versioned')
}
if (finalize && !model.validation.ok) {
throw new Error('Annual report has blocking validation errors and cannot be finalized')
}
+1 -1
View File
@@ -9,7 +9,7 @@
* class 3-8 account is zeroed (equal debit/credit churn) and 2099
* carries the year's result.
* - `preClosing`: the same year WITHOUT the closing entry
* (excludeYearEndClosing): RR accounts still open, 2099 only carries
* (excludeFinalClosingEntry): RR accounts still open, 2099 only carries
* the prior-year churn from the resultatdisposition entry.
*/
@@ -82,6 +82,42 @@ describe('generateK2IxbrlDocument', () => {
expect(xhtml).toMatch(/contextRef="balans1" name="se-gen-base:Tillgangar"[^>]*>253 000/)
})
it('emits account 7833 depreciation and debit 2650 as the correct iXBRL facts', () => {
const balance = (account: string, name: string, debit: number, credit: number) => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
const full = [
balance('1250', 'Computers', 50, 0),
balance('1259', 'Accumulated depreciation', 0, 10),
balance('1930', 'Bank', 75, 0),
balance('2081', 'Share capital', 0, 50),
balance('2099', 'Current-year result', 0, 90),
balance('2650', 'VAT settlement account', 25, 0),
]
const preClosing = [
...full.filter((row) => row.account_number !== '2099'),
balance('3010', 'Revenue', 0, 100),
balance('7833', 'Depreciation of computers', 10, 0),
]
const mapping = mapTrialBalancesToK2({ full, preClosing }, null)
const input = makeInput()
input.rr = mapping.rr
input.br = mapping.br
input.totals = mapping.totals
const { xhtml: reportedAccountsXhtml } = generateK2IxbrlDocument(input)
expect(reportedAccountsXhtml).toMatch(
/name="se-gen-base:AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar"[^>]*>10<\/ix:nonFraction>/,
)
expect(reportedAccountsXhtml).toMatch(
/name="se-gen-base:OvrigaFordringarKortfristiga"[^>]*>25<\/ix:nonFraction>/,
)
})
it('tags the underskrifter tuple with per-signer dates (TA §2.9.1)', () => {
expect(xhtml).toContain('se-gaap-ext:UnderskriftArsredovisningForetradareTuple')
const tilltalsnamn = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
+203 -7
View File
@@ -92,6 +92,144 @@ describe('mapTrialBalancesToK2', () => {
expect(result.totals.aretsResultat.current).toBe(result.br['AretsResultatEgetKapital'].current)
})
it('reclassifies tax and VAT balances by economic sign', () => {
const rows = [
row('1930', 'Bank', 100, 0),
row('1630', 'Tax account', 0, 20),
row('2518', 'Paid preliminary tax', 30, 0),
row('2641', 'Input VAT', 5, 0),
row('2081', 'Share capital', 0, 115),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(35)
expect(res.br['Skatteskulder'].current).toBe(20)
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(0)
expect(res.totals.tillgangar.current).toBe(135)
expect(res.totals.egetKapitalSkulder.current).toBe(135)
expect(res.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining('1630-1659'),
expect.stringContaining('2500-2599'),
expect.stringContaining('2610-2659'),
]),
)
})
it('maps account 7833 depreciation from the statutory pre-closing balance', () => {
const full = [
row('1250', 'Computers', 50, 0),
row('1259', 'Accumulated depreciation', 0, 10),
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 50),
row('2099', 'Current-year result', 0, 90),
]
const preClosing = [
...full.filter((balance) => balance.account_number !== '2099'),
row('3010', 'Revenue', 0, 100),
row('7833', 'Depreciation of computers', 10, 0),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(
res.rr['AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar']
.current,
).toBe(10)
expect(res.totals.aretsResultat.current).toBe(90)
expect(res.br['AretsResultatEgetKapital'].current).toBe(90)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
})
it('presents a debit on account 2650 as a receivable for each comparison year', () => {
const current = [
row('1930', 'Bank', 75, 0),
row('2081', 'Share capital', 0, 100),
row('2650', 'VAT settlement account', 25, 0),
]
const previous = [
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 75),
row('2650', 'VAT settlement account', 0, 25),
]
const res = mapTrialBalancesToK2(
{ full: current, preClosing: current },
{ full: previous, preClosing: previous },
)
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 25, previous: 0 })
expect(res.br['OvrigaKortfristigaSkulder']).toEqual({ current: 0, previous: 25 })
expect(res.totals.tillgangar).toEqual({ current: 100, previous: 100 })
expect(res.totals.egetKapitalSkulder).toEqual({ current: 100, previous: 100 })
})
it('nets paid preliminary tax against the current tax liability', () => {
const rows = [
row('1930', 'Bank', 100, 0),
row('2512', 'Current tax', 0, 123.18),
row('2518', 'Paid preliminary tax', 23.18, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(0)
})
it('nets domestic VAT without offsetting excise duty', () => {
const rows = [
row('1930', 'Bank', 15, 0),
row('2611', 'Output VAT', 0, 50),
row('2641', 'Input VAT', 75, 0),
row('2660', 'Excise duty', 0, 40),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(25)
expect(res.br['OvrigaKortfristigaSkulder'].current).toBe(40)
expect(res.totals.tillgangar.current).toBe(40)
expect(res.totals.egetKapitalSkulder.current).toBe(40)
})
it('reclassifies a previous-year tax-account credit independently', () => {
const current = [
row('1930', 'Bank', 100, 0),
row('2081', 'Share capital', 0, 100),
]
const previous = [
row('1930', 'Bank', 20, 0),
row('1630', 'Tax account', 0, 20),
]
const res = mapTrialBalancesToK2(
{ full: current, preClosing: current },
{ full: previous, preClosing: previous },
)
expect(res.br['OvrigaFordringarKortfristiga']).toEqual({ current: 0, previous: 0 })
expect(res.br['Skatteskulder']).toEqual({ current: 0, previous: 20 })
expect(res.warnings).toContainEqual(expect.stringContaining('1630-1659'))
})
it('does not offset a tax-account liability against a separate tax receivable', () => {
const rows = [
row('1630', 'Tax account', 0, 100),
row('1650', 'VAT receivable', 100, 0),
row('2081', 'Share capital', 0, 100),
row('1930', 'Bank', 100, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['OvrigaFordringarKortfristiga'].current).toBe(100)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.totals.tillgangar.current).toBe(200)
expect(res.totals.egetKapitalSkulder.current).toBe(200)
})
// Regression for the year-end-closing split: a realistic post-bokslut TB
// pair must yield NON-ZERO RR concepts (from the pre-closing TB) AND a BR
// that ties (from the full TB). Mapping a single TB can never do both: the
@@ -165,11 +303,11 @@ describe('mapTrialBalancesToK2', () => {
})
describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
it('absorbs a ±1 kr BR residual into the largest equity/liability post', () => {
it('absorbs a ±1 kr BR residual into a post with an exact öre balance', () => {
// Assets round UP twice (.50 each), liabilities round once up once down:
// rounded Tillgångar 202 vs rounded EK+skulder 201 although the TB ties
// exactly at 201,00. The +1 residual lands in the largest post on the
// equity/liabilities side (Leverantörsskulder).
// exactly at 201,00. The +1 residual lands on a fractional post, never
// on an unrelated exact whole-krona balance.
const rows = [
row('1510', 'Kundfordringar', 100.5, 0),
row('1930', 'Bank', 100.5, 0),
@@ -177,9 +315,10 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
row('2510', 'Skatteskulder', 0, 100.25),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(202)
expect(res.totals.egetKapitalSkulder.current).toBe(202)
expect(res.br['Leverantorsskulder'].current).toBe(102)
expect(res.totals.tillgangar.current).toBe(201)
expect(res.totals.egetKapitalSkulder.current).toBe(201)
expect(res.br['Kundfordringar'].current).toBe(100)
expect(res.br['Leverantorsskulder'].current).toBe(101)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.warnings).toEqual([])
})
@@ -207,7 +346,64 @@ describe('mapTrialBalancesToK2: öre-rounding residual smoothing', () => {
expect(res.warnings).toEqual([])
})
it('leaves residuals beyond ±1 kr alone and reports them', () => {
it('distributes a multi-krona BR residual over fractional posts', () => {
const rows = [
row('1510', 'Trade receivable', 0.5, 0),
row('1630', 'Tax account', 0.5, 0),
row('1710', 'Prepaid expense', 0.5, 0),
row('1810', 'Short-term investment', 0.5, 0),
row('1930', 'Bank', 0.5, 0),
row('2081', 'Share capital', 0, 2.5),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(3)
expect(res.totals.egetKapitalSkulder.current).toBe(3)
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
})
it('normalizes exact öre sums before whole-krona rounding', () => {
const rows = [
row('1510', 'Trade receivable', 0.03, 0),
row('1710', 'Prepaid expense', 0.29, 0),
row('1930', 'Bank', 0.18, 0),
row('2081', 'Share capital', 0, 0.5),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(1)
expect(res.totals.egetKapitalSkulder.current).toBe(1)
expect(res.warnings.some((warning) => warning.includes('3005'))).toBe(false)
})
it('distributes a multi-krona RR residual over fractional posts', () => {
const incomeRows = [
row('3010', 'Revenue', 0, 0.5),
row('3810', 'Capitalized work', 0, 0.5),
row('3910', 'Other income', 0, 0.5),
row('8010', 'Group result', 0, 0.5),
row('8310', 'Interest income', 0, 0.5),
]
const preClosing = [row('1930', 'Bank', 2.5, 0), ...incomeRows]
const full = [
row('1930', 'Bank', 2.5, 0),
row('2099', 'Current result', 0, 2.5),
...incomeRows.map((incomeRow) => ({
...incomeRow,
closing_debit: incomeRow.closing_credit,
})),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(res.totals.aretsResultat.current).toBe(3)
expect(res.br['AretsResultatEgetKapital'].current).toBe(3)
expect(res.warnings.some((warning) => warning.includes('2099'))).toBe(false)
})
it('leaves real bookkeeping differences alone and reports them', () => {
const rows = [
row('1930', 'Bank', 1_000, 0),
row('2440', 'Leverantörsskulder', 0, 990),
+4 -4
View File
@@ -58,8 +58,8 @@ export async function buildIxbrlInput(
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
// (year-end closing included → 2099 booked, class 3-8 zeroed) drives the
// BR; the PRE-CLOSING trial balance (excludeYearEndClosing: the same split
// lib/reports' generateIncomeStatement uses) drives the RR. A single TB can
// BR; the PRE-CLOSING trial balance (excludeFinalClosingEntry) drives the
// RR while retaining tax and appropriations. A single TB can
// never serve both: with bokslut booked every RR concept would map to 0,
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
@@ -72,7 +72,7 @@ export async function buildIxbrlInput(
.eq('company_id', companyId)
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
@@ -102,7 +102,7 @@ export async function buildIxbrlInput(
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id),
generateTrialBalance(supabase, companyId, prev.id, { excludeYearEndClosing: true }),
generateTrialBalance(supabase, companyId, prev.id, { excludeFinalClosingEntry: true }),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
+212 -67
View File
@@ -15,6 +15,7 @@
*/
import type { ConceptAmount, ConceptAmounts } from './types'
import { equalOre, roundOre, sumOre } from '@/lib/money'
export interface TrialBalanceRowLike {
account_number: string
@@ -29,7 +30,7 @@ export interface TrialBalanceRowLike {
* never serve both statements:
* - `full` (including the closing entry) carries the booked 2099 and the
* correct equity: it drives the BR concepts.
* - `preClosing` (generateTrialBalance with excludeYearEndClosing: true)
* - `preClosing` (generateTrialBalance with excludeFinalClosingEntry: true)
* still has the RR accounts open: it drives the RR concepts.
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
* the same source.
@@ -51,6 +52,15 @@ interface PostMapping {
ranges: Range[]
}
interface SignReclassification {
sourceConcept: string
targetConcept: string
balance: 'debit' | 'credit'
ranges: Range[]
mode: 'net' | 'deviating_rows'
warning: string
}
const r = (start: string, end: string): Range => ({ start, end })
/** RR: kostnadsslagsindelad (risbs), in uppställningsform order. */
@@ -383,6 +393,42 @@ const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond: granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
}
/**
* Tax settlement and VAT accounts can carry the opposite economic balance
* from their BAS class. K2 presentation follows the balance's substance:
* a tax-account credit is a liability, while a net debit on tax or VAT
* liability accounts is a current receivable.
*/
const SIGN_RECLASSIFICATIONS: SignReclassification[] = [
{
sourceConcept: 'OvrigaFordringarKortfristiga',
targetConcept: 'Skatteskulder',
balance: 'debit',
ranges: [r('1630', '1659')],
mode: 'deviating_rows',
warning:
'Skatte- och momsfordringskonton 1630-1659 har ett nettokreditsaldo och har därför redovisats som skatteskuld.',
},
{
sourceConcept: 'Skatteskulder',
targetConcept: 'OvrigaFordringarKortfristiga',
balance: 'credit',
ranges: [r('2500', '2599')],
mode: 'net',
warning:
'Skatteskuldkonton 2500-2599 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
},
{
sourceConcept: 'OvrigaKortfristigaSkulder',
targetConcept: 'OvrigaFordringarKortfristiga',
balance: 'credit',
ranges: [r('2610', '2659')],
mode: 'net',
warning:
'Momsavräkningskonton 2610-2659 har ett nettodebetsaldo och har därför redovisats som övrig fordran.',
},
]
export interface K2MappingResult {
rr: ConceptAmounts
br: ConceptAmounts
@@ -421,7 +467,7 @@ export interface K2MappingResult {
}
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
const net = row.closing_debit - row.closing_credit
const net = roundOre(row.closing_debit - row.closing_credit)
return orientation === 'debit' ? net : -net
}
@@ -429,34 +475,80 @@ function inRanges(account: string, ranges: Range[]): boolean {
return ranges.some((range) => account >= range.start && account <= range.end)
}
function sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
let total = 0
for (const row of rows) {
if (inRanges(row.account_number, mapping.ranges)) {
total += netBalance(row, mapping.balance)
}
}
return Math.round(total)
function exactSumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, mapping.ranges))
.map((row) => netBalance(row, mapping.balance)),
)
}
function amount(
function roundWhole(amount: number): number {
const normalized = roundOre(amount)
const rounded = Math.round(Math.abs(normalized))
return rounded === 0 ? 0 : Math.sign(normalized) * rounded
}
function exactAmount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: sumForMapping(current, mapping),
previous: previous ? sumForMapping(previous, mapping) : null,
current: exactSumForMapping(current, mapping),
previous: previous ? exactSumForMapping(previous, mapping) : null,
}
}
function deviatingRowsTotal(
rows: TrialBalanceRowLike[],
rule: SignReclassification,
): number {
return sumOre(
rows
.filter((row) => inRanges(row.account_number, rule.ranges))
.map((row) => netBalance(row, rule.balance))
.filter((balance) => balance < 0),
)
}
function applySignReclassifications(
br: ConceptAmounts,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
warnings: string[],
): void {
for (const rule of SIGN_RECLASSIFICATIONS) {
let reclassified = false
for (const field of ['current', 'previous'] as const) {
const rows = field === 'current' ? current : previous
if (!rows) continue
const deviatingBalance =
rule.mode === 'deviating_rows'
? deviatingRowsTotal(rows, rule)
: exactSumForMapping(rows, {
concept: rule.sourceConcept,
balance: rule.balance,
ranges: rule.ranges,
})
if (deviatingBalance >= 0) continue
const amountToMove = -deviatingBalance
adjustConcept(br, rule.sourceConcept, field, amountToMove)
adjustConcept(br, rule.targetConcept, field, amountToMove)
reclassified = true
}
if (reclassified) warnings.push(rule.warning)
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: a.current + sign * b.current,
current: roundOre(a.current + sign * b.current),
previous:
a.previous === null && b.previous === null
? null
: (a.previous ?? 0) + sign * (b.previous ?? 0),
: roundOre((a.previous ?? 0) + sign * (b.previous ?? 0)),
}
}
@@ -486,13 +578,32 @@ export function mapTrialBalancesToK2(
): K2MappingResult {
const warnings: string[] = []
const rr: ConceptAmounts = {}
const rrExact: ConceptAmounts = {}
const br: ConceptAmounts = {}
const brExact: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
rrExact[mapping.concept] = exactAmount(
mapping,
current.preClosing,
previous?.preClosing ?? null,
)
const exact = rrExact[mapping.concept]
rr[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
for (const mapping of K2_BR_MAPPINGS) {
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
brExact[mapping.concept] = exactAmount(mapping, current.full, previous?.full ?? null)
}
applySignReclassifications(brExact, current.full, previous?.full ?? null, warnings)
for (const mapping of K2_BR_MAPPINGS) {
const exact = brExact[mapping.concept]
br[mapping.concept] = {
current: roundWhole(exact.current),
previous: exact.previous === null ? null : roundWhole(exact.previous),
}
}
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
@@ -536,21 +647,20 @@ export function mapTrialBalancesToK2(
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
// distributed back into a line item instead of tolerated. Deterministic
// rule, per year:
// - BR: the residual (Tillgångar Eget kapital och skulder) is added to
// the largest post (by absolute value) on the equity/liabilities side,
// excluding AretsResultatEgetKapital, whose value must stay equal to
// the booked 2099 / RR result (ties broken toward the LATER post in
// the uppställningsform, so liabilities win over aktiekapital).
// - BR: each side is reconciled to its rounded exact total. The residual
// is assigned only to a post with an exact öre amount on that side.
// Exact whole-krona posts, such as a booked reserve, are never changed.
// - RR: the residual (RR-resultat konto 2099) is absorbed by the
// largest RR post: cost posts are increased by the residual, income
// posts decreased (ties broken toward the EARLIER post).
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
// exact balance checks below.
// Multi-krona residuals are distributed over multiple fractional posts.
// A residual without enough fractional posts is left for the exact balance
// checks below instead of changing a booked whole-krona amount.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
const brSmoothed = smoothBrResidual(br, totals, field)
const rrSmoothed = smoothRrResidual(rr, rrExact, br, brExact, totals, field)
const brSmoothed = smoothBrResidual(br, brExact, totals, field)
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
}
if (smoothedAny) totals = computeTotals(rr, br)
@@ -574,28 +684,6 @@ export function mapTrialBalancesToK2(
return { rr, br, totals, warnings, unmappedAccounts }
}
function pickLargestConcept(
amounts: ConceptAmounts,
mappings: PostMapping[],
field: 'current' | 'previous',
exclude: ReadonlySet<string>,
tieBreak: 'first' | 'last',
): string | null {
let best: string | null = null
let bestAbs = -1
for (const mapping of mappings) {
if (exclude.has(mapping.concept)) continue
const value = amounts[mapping.concept]?.[field]
if (value === null || value === undefined || value === 0) continue
const abs = Math.abs(value)
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
best = mapping.concept
bestAbs = abs
}
}
return best
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
@@ -603,13 +691,15 @@ function adjustConcept(
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
amounts[concept] = { ...existing, [field]: roundOre((existing[field] ?? 0) + delta) }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
rrExact: ConceptAmounts,
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
@@ -617,39 +707,94 @@ function smoothRrResidual(
const result = totals.aretsResultat[field]
if (target === null || target === undefined || result === null) return false
const diff = result - target
if (diff === 0 || Math.abs(diff) > 1) return false
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
if (!concept) return false
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
// Debit (cost) posts enter the result with weight 1, credit (income)
// posts with +1: adjust so the recomputed result lands on the 2099 value.
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
if (diff === 0) return false
const exactResult = computeTotals(rrExact, brExact).aretsResultat[field]
const exactTarget = brExact['AretsResultatEgetKapital']?.[field]
if (exactResult === null || exactTarget === null || exactTarget === undefined) return false
if (!equalOre(exactResult, exactTarget)) return false
const direction = Math.sign(diff)
const candidates = K2_RR_MAPPINGS.flatMap((mapping, index) => {
const rounded = rr[mapping.concept]?.[field]
const exact = rrExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
const delta = mapping.balance === 'debit' ? direction : -direction
return [{ concept: mapping.concept, delta, error: Math.abs(rounded + delta - exact), index }]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(diff)) return false
for (const candidate of candidates.slice(0, Math.abs(diff))) {
adjustConcept(rr, candidate.concept, field, candidate.delta)
}
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const FIRST_EQ_LIAB_MAPPING_INDEX = K2_BR_MAPPINGS.findIndex(
(mapping) => mapping.concept === 'Aktiekapital',
)
const ASSET_MAPPINGS = K2_BR_MAPPINGS.slice(0, FIRST_EQ_LIAB_MAPPING_INDEX)
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
FIRST_EQ_LIAB_MAPPING_INDEX,
)
/** Absorb a ±1 kr rounding residual between the two BR sides. */
/**
* Reconcile each BR side to its own rounded exact total without changing exact
* posts. Residuals must not be netted across sides: doing so could make the
* balance check pass while leaving one reported side different from its exact
* accounting total.
*/
function smoothBrResidual(
br: ConceptAmounts,
brExact: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const assets = totals.tillgangar[field]
const eqLiab = totals.egetKapitalSkulder[field]
if (assets === null || eqLiab === null) return false
const diff = assets - eqLiab
if (diff === 0 || Math.abs(diff) > 1) return false
const concept =
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
'BalanseratResultat'
// All equity/liability posts are credit-oriented: adding the residual
// raises the eget kapital och skulder side to match Tillgångar.
adjustConcept(br, concept, field, diff)
return true
const exactTotals = computeTotals({}, brExact)
const exactAssets = exactTotals.tillgangar[field]
const exactEqLiab = exactTotals.egetKapitalSkulder[field]
if (exactAssets === null || exactEqLiab === null) return false
if (!equalOre(exactAssets, exactEqLiab)) return false
const sides = [
{ mappings: ASSET_MAPPINGS, rounded: assets, target: roundWhole(exactAssets) },
{ mappings: EQ_LIAB_MAPPINGS, rounded: eqLiab, target: roundWhole(exactEqLiab) },
]
const residuals = sides.map((side) => side.target - side.rounded)
if (residuals.every((residual) => residual === 0)) return false
const plans = sides.map((side, index) => {
const residual = residuals[index]
if (residual === 0) return []
const direction = Math.sign(residual)
const candidates = side.mappings.flatMap((mapping, mappingIndex) => {
if (mapping.concept === 'AretsResultatEgetKapital') return []
const rounded = br[mapping.concept]?.[field]
const exact = brExact[mapping.concept]?.[field]
if (rounded === null || rounded === undefined || exact === null || exact === undefined) return []
if (Math.abs(exact - rounded) < 0.000001) return []
return [{
concept: mapping.concept,
error: Math.abs(rounded + direction - exact),
index: mappingIndex,
}]
}).sort((left, right) => left.error - right.error || left.index - right.index)
if (candidates.length < Math.abs(residual)) return null
return candidates.slice(0, Math.abs(residual)).map((candidate) => ({
concept: candidate.concept,
delta: direction,
}))
})
if (plans.some((plan) => plan === null)) return false
for (const plan of plans) {
for (const adjustment of plan ?? []) {
adjustConcept(br, adjustment.concept, field, adjustment.delta)
}
}
return plans.some((plan) => (plan?.length ?? 0) > 0)
}
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
@@ -431,6 +431,43 @@ describe('invoice email templates', () => {
expect(text).toMatch(/1[\s ]234,56 EUR/)
})
it('uses the matching EUR payment account in both email variants', () => {
const eurInvoice = makeInvoice({ invoice_number: '1042', currency: 'EUR', total: 1234.56 })
const multiCurrencyCompany = makeCompanySettings({
bank_name: 'Legacy SEK Bank',
clearing_number: '5037',
account_number: '1231231',
iban: 'SE0011111111111111111111',
bic: 'NDEASESS',
invoice_payment_accounts: {
EUR: {
bank_name: 'Mock ASPSP',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE4550000000058398257466',
bic: 'ESSESESS',
},
},
})
const data = { invoice: eurInvoice, customer: svCustomer, company: multiCurrencyCompany }
const html = generateInvoiceEmailHtml(data)
const text = generateInvoiceEmailText(data)
for (const rendered of [html, text]) {
expect(rendered).toContain('Mock ASPSP')
expect(rendered).toContain('SE4550000000058398257466')
expect(rendered).toContain('ESSESESS')
expect(rendered).not.toContain('Legacy SEK Bank')
expect(rendered).not.toContain('5037-1231231')
expect(rendered).not.toContain('SE0011111111111111111111')
expect(rendered).not.toContain('NDEASESS')
}
})
it('subtracts the ROT/RUT deduction so the email states what the customer owes', () => {
const rotInvoice = makeInvoice({ invoice_number: '1042', total: 1234.56, deduction_total: 500 })
const html = generateInvoiceEmailHtml({ invoice: rotInvoice, customer: svCustomer, company })
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { companyWithInvoicePaymentAccount } from '@/lib/invoices/payment-accounts'
import { applyPlaceholders, escapeHtml, sanitizeSubjectLine, userTextToHtml } from './user-text'
type EmailLang = 'sv' | 'en'
@@ -197,7 +198,8 @@ function safeBrandingColor(value: string | null | undefined, fallback: string):
* Generate HTML email for sending an invoice
*/
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
@@ -353,7 +355,8 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
* Generate plain text email for sending an invoice
*/
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const { invoice, customer } = data
const company = companyWithInvoicePaymentAccount(data.company, invoice.currency)
const lang = resolveLang(customer)
const L = LABELS[lang]
+1
View File
@@ -9,6 +9,7 @@
export interface SendEmailOptions {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text?: string
+16 -2
View File
@@ -885,11 +885,25 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_en: 'Customer has no email address.',
remediation: { description: 'Add an email address on the customer record before sending.' },
},
INVOICE_SEND_TOO_MANY_RECIPIENTS: {
httpStatus: 400,
message_sv: 'Ett fakturautskick får ha högst 20 mottagare totalt.',
message_en: 'An invoice email may have at most 20 recipients in total.',
remediation: { description: 'Remove CC or BCC recipients before sending the invoice.' },
},
INVOICE_SEND_COMPANY_SETTINGS_MISSING: {
httpStatus: 404,
message_sv: 'Företagsinställningar saknas.',
message_en: 'Company settings are missing.',
},
INVOICE_SEND_PAYMENT_ACCOUNT_MISSING: {
httpStatus: 400,
message_sv: 'Fakturan saknar ett betalningskonto för vald valuta. Lägg till kontot under Fakturering innan du skapar PDF-filen eller skickar fakturan.',
message_en: 'The invoice has no payment account for its currency. Add the account under Invoicing before generating the PDF or sending the invoice.',
remediation: {
description: 'Lägg till ett betalningskonto med IBAN för fakturans valuta under Fakturering.',
},
},
INVOICE_SEND_NUMBER_ASSIGN_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tilldela fakturanummer.',
@@ -2848,8 +2862,8 @@ const BOLAGSVERKET: Record<string, StructuredErrorEntry> = {
},
ARSREDOVISNING_INCOMPLETE: {
httpStatus: 409,
message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte låsas ännu.',
message_en: 'The annual report has blocking validation errors and cannot be finalized yet.',
message_sv: 'Årsredovisningen har blockerande kontrollfel och kan inte versionssparas ännu.',
message_en: 'The annual report has blocking validation errors and cannot be versioned yet.',
retryable: false,
},
ARSREDOVISNING_VERSION_NOT_SIGNABLE: {
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
exceedsInvoiceEmailRecipientLimit,
findAdditionalInvoiceRecipientCollisions,
invoiceEmailRecipientCount,
parseInvoiceRecipientText,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
describe('resolveInvoiceEmailRecipients', () => {
it('uses the legacy copy only while the company list is unconfigured', () => {
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: null,
legacyCc: 'billing@example.test',
}).cc).toEqual(['billing@example.test'])
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: [],
legacyCc: 'billing@example.test',
}).cc).toEqual([])
})
it('merges fixed and per-send recipients with deterministic precedence', () => {
expect(resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: ['finance@example.test', 'CUSTOMER@example.test'],
configuredBcc: ['archive@example.test', 'finance@example.test'],
additionalCc: ['handler@example.test', 'Finance@example.test'],
additionalBcc: ['director@example.test', 'archive@example.test'],
})).toEqual({
to: ['customer@example.test'],
cc: ['finance@example.test', 'handler@example.test'],
bcc: ['archive@example.test', 'director@example.test'],
})
})
it('counts the final de-duplicated To, CC, and BCC recipients', () => {
const atLimit = resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: Array.from({ length: 19 }, (_, index) => `copy-${index}@example.test`),
})
expect(invoiceEmailRecipientCount(atLimit)).toBe(20)
expect(exceedsInvoiceEmailRecipientLimit(atLimit)).toBe(false)
const overLimit = resolveInvoiceEmailRecipients({
to: 'customer@example.test',
configuredCc: atLimit.cc,
additionalBcc: ['archive@example.test'],
})
expect(invoiceEmailRecipientCount(overLimit)).toBe(21)
expect(exceedsInvoiceEmailRecipientLimit(overLimit)).toBe(true)
})
it('trims and de-duplicates address text', () => {
expect(parseInvoiceRecipientText(
' finance@example.test,\nDIRECTOR@example.test; finance@example.test ',
)).toEqual(['finance@example.test', 'DIRECTOR@example.test'])
})
it('reports per-send collisions instead of silently changing recipient precedence', () => {
expect(findAdditionalInvoiceRecipientCollisions({
to: 'customer@example.test',
configuredCc: ['finance@example.test'],
configuredBcc: ['archive@example.test'],
additionalCc: ['CUSTOMER@example.test', 'case-owner@example.test'],
additionalBcc: ['finance@example.test', 'case-owner@example.test'],
})).toEqual([
{
address: 'CUSTOMER@example.test',
field: 'additional_cc',
conflicts_with: 'to',
},
{
address: 'finance@example.test',
field: 'additional_bcc',
conflicts_with: 'configured_cc',
},
{
address: 'case-owner@example.test',
field: 'additional_bcc',
conflicts_with: 'additional_cc',
},
])
})
})
@@ -1,8 +1,33 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import type { PoolClient } from 'pg'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
async function withServiceRoleContext<T>(
userId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
JSON.stringify({ sub: userId, role: 'service_role' }),
])
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
await client.query(`SELECT set_config('request.jwt.claim.role', 'service_role', true)`)
await client.query(`SET LOCAL ROLE service_role`)
const result = await fn(client)
await client.query('ROLLBACK')
return result
} catch (error) {
await client.query('ROLLBACK').catch(() => {})
throw error
} finally {
client.release()
}
}
async function insertInvoice(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
const invoiceId = randomUUID()
@@ -68,11 +93,11 @@ async function insertPendingEmailDelivery(params: {
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status,
to_addresses, cc_addresses, reply_to, from_name, subject,
to_addresses, cc_addresses, bcc_addresses, reply_to, from_name, subject,
body_text, body_html, document_attachment_id, attachment_filename,
attachment_content_type, attachment_sha256, retention_expires_at)
VALUES ($1, $2, $3, $4, 'email', 'pending',
ARRAY['customer@example.com'], ARRAY['copy@example.com'],
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6, $7)`,
@@ -155,6 +180,16 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(),
bcc_addresses = ARRAY['changed@example.com']
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('rejects invoice and document references from another company', async () => {
@@ -226,6 +261,119 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
expect(visibleIds).toEqual([deliveryA])
})
it('keeps exact payload sender-only and exposes masked summaries to members', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[deliveryId],
)
const directRows = await withUserContext(memberId, async (client) => {
return client.query(
`SELECT id, bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
})
expect(directRows.rowCount).toBe(0)
const summary = await withUserContext(memberId, async (client) => {
return client.query<Record<string, unknown>>(
`SELECT *
FROM public.list_invoice_delivery_summaries($1, $2)`,
[companyId, invoiceId],
)
})
expect(summary.rows).toEqual([
expect.objectContaining({
id: deliveryId,
to_addresses: ['***@example.com'],
cc_addresses: ['***@example.com'],
}),
])
expect(summary.rows[0]).not.toHaveProperty('bcc_addresses')
expect(summary.rows[0]).not.toHaveProperty('body_text')
const documentLookup = await withUserContext(memberId, (client) => client.query<{ id: string }>(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
[companyId, invoiceId],
))
expect(documentLookup.rows[0].id).toBe(documentId)
const other = await seedCompany()
const otherInvoiceId = await insertInvoice(other.userId, other.companyId)
const otherDocumentId = await insertDocument(other.userId, other.companyId)
const otherDeliveryId = await insertPendingEmailDelivery({
userId: other.userId,
companyId: other.companyId,
invoiceId: otherInvoiceId,
documentId: otherDocumentId,
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[otherDeliveryId],
)
const mismatchedInvoiceLookup = await withUserContext(memberId, (client) =>
client.query<{ id: string | null }>(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)::text AS id`,
[companyId, otherInvoiceId],
),
)
expect(mismatchedInvoiceLookup.rows[0].id).toBeNull()
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.latest_sent_invoice_delivery_document($1, $2)`,
[other.companyId, otherInvoiceId],
)),
).rejects.toThrow(/not authorized to find delivered invoice document/i)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT id FROM public.export_invoice_delivery_evidence($1)`,
[companyId],
)),
).rejects.toThrow(/owner or admin role required/i)
const ownerExport = await withUserContext(userId, (client) => client.query<{
id: string
bcc_addresses: string[]
}>(
`SELECT id, bcc_addresses
FROM public.export_invoice_delivery_evidence($1)
WHERE id = $2`,
[companyId, deliveryId],
))
expect(ownerExport.rows[0]).toEqual({
id: deliveryId,
bcc_addresses: ['archive@example.com'],
})
const senderPayload = await withUserContext(userId, async (client) => {
return client.query<{ bcc_addresses: string[]; body_text: string }>(
`SELECT bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
})
expect(senderPayload.rows[0]).toEqual({
bcc_addresses: ['archive@example.com'],
body_text: 'Exact plain text',
})
})
it('denies inserts to a viewer', async () => {
const { userId, companyId } = await seedCompany()
const viewerId = await insertAuthUser()
@@ -244,6 +392,173 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
).rejects.toThrow(/row-level security|policy/i)
})
it('denies direct delivery writes and RPC execution to authenticated members', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
withUserContext(memberId, (client) => client.query(
`INSERT INTO public.invoice_deliveries
(user_id, company_id, invoice_id, channel, status, sent_at)
VALUES ($1, $2, $3, 'manual', 'marked_sent', now())`,
[memberId, companyId, invoiceId],
)),
).rejects.toThrow(/row-level security|policy/i)
const directUpdate = await withUserContext(userId, (client) => client.query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now()
WHERE id = $1`,
[deliveryId],
))
expect(directUpdate.rowCount).toBe(0)
await expect(
withUserContext(memberId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[companyId, invoiceId, userId],
)),
).rejects.toThrow(/permission denied/i)
})
it('uses server-only RPCs for reservation, payload capture, and finalization', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
await withServiceRoleContext(userId, async (client) => {
const reserved = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
const deliveryId = reserved.rows[0].id
const reused = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
expect(reused.rows[0].id).toBe(deliveryId)
const captured = await client.query<{ id: string }>(
`SELECT public.capture_invoice_delivery_payload(
$1, $2, $3, $4,
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6
)::text AS id`,
[deliveryId, companyId, invoiceId, userId, documentId, 'a'.repeat(64)],
)
expect(captured.rows[0].id).toBe(deliveryId)
const finalized = await client.query<{ id: string }>(
`SELECT public.finalize_invoice_delivery(
$1, $2, $3, 'sent', 'resend', 'provider-message-1', NULL
)::text AS id`,
[deliveryId, companyId, userId],
)
expect(finalized.rows[0].id).toBe(deliveryId)
const row = await client.query(
`SELECT status, bcc_addresses, body_text
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(row.rows[0]).toMatchObject({
status: 'sent',
bcc_addresses: ['archive@example.com'],
body_text: 'Exact plain text',
})
})
})
it('rejects service-role delivery writes for a non-member or mismatched tenant', async () => {
const first = await seedCompany()
const second = await seedCompany()
const outsiderId = await insertAuthUser()
const invoiceId = await insertInvoice(first.userId, first.companyId)
await expect(
withServiceRoleContext(outsiderId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[first.companyId, invoiceId, outsiderId],
)),
).rejects.toThrow(/writable company member/i)
await expect(
withServiceRoleContext(second.userId, (client) => client.query(
`SELECT public.reserve_invoice_delivery($1, $2, $3)`,
[second.companyId, invoiceId, second.userId],
)),
).rejects.toThrow(/invoice not found/i)
})
it('reclaims only stale payload-free reservations for another sender', async () => {
const { userId, companyId } = await seedCompany()
const otherUserId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: otherUserId, role: 'admin' })
const invoiceId = await insertInvoice(userId, companyId)
const staleId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status, created_at)
VALUES ($1, $2, $3, $4, 'email', 'preparing', now() - interval '16 minutes')`,
[staleId, userId, companyId, invoiceId],
)
await withServiceRoleContext(otherUserId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, otherUserId],
)
expect(result.rows[0].id).not.toBe(staleId)
const stale = await client.query(
`SELECT id FROM public.invoice_deliveries WHERE id = $1`,
[staleId],
)
expect(stale.rowCount).toBe(0)
})
})
it('restricts fixed invoice email recipient settings to owners and admins', async () => {
const { userId, companyId } = await seedCompany()
const memberId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
await getPool().query(
`INSERT INTO public.company_settings (user_id, company_id)
VALUES ($1, $2)`,
[userId, companyId],
)
const memberUpdate = await withUserContext(memberId, (client) => client.query(
`UPDATE public.company_settings
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
WHERE company_id = $1`,
[companyId],
))
expect(memberUpdate.rowCount).toBe(0)
const ownerUpdate = await withUserContext(userId, (client) => client.query(
`UPDATE public.company_settings
SET invoice_email_bcc_addresses = ARRAY['archive@example.com']
WHERE company_id = $1`,
[companyId],
))
expect(ownerUpdate.rowCount).toBe(1)
})
it('reserves one preparing attempt and promotes it to the exact pending payload', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
@@ -288,7 +603,29 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
[deliveryId],
)
expect(result.rows[0].status).toBe('pending')
expect(result.rows[0].retention_expires_at).toBeTruthy()
expect(new Date(result.rows[0].retention_expires_at).toISOString().slice(0, 10)).toBe(
'2034-01-01',
)
const nextReservationId = await withServiceRoleContext(userId, async (client) => {
const reservation = await client.query<{ id: string }>(
`SELECT public.reserve_invoice_delivery($1, $2, $3)::text AS id`,
[companyId, invoiceId, userId],
)
const states = await client.query<{ id: string; status: string }>(
`SELECT id, status
FROM public.invoice_deliveries
WHERE company_id = $1 AND invoice_id = $2
ORDER BY created_at`,
[companyId, invoiceId],
)
expect(states.rows).toEqual(expect.arrayContaining([
{ id: deliveryId, status: 'pending' },
{ id: reservation.rows[0].id, status: 'preparing' },
]))
return reservation.rows[0].id
})
expect(nextReservationId).not.toBe(deliveryId)
})
it('allows a failed attempt to release and delete its unsent PDF', async () => {
@@ -340,7 +677,7 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query(
`SELECT to_addresses, body_text, subject, provider_message_id,
`SELECT to_addresses, cc_addresses, bcc_addresses, body_text, subject, provider_message_id,
attachment_filename, attachment_sha256, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
@@ -348,6 +685,8 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
)
expect(delivery.rows[0]).toMatchObject({
to_addresses: [],
cc_addresses: [],
bcc_addresses: [],
body_text: null,
subject: null,
provider_message_id: null,
+109 -88
View File
@@ -4,10 +4,14 @@ import type { EmailService } from '@/lib/email/service'
const mockUploadDocument = vi.fn()
const mockDeleteDocument = vi.fn()
const mockCreateServiceClient = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockCreateServiceClient(),
}))
import {
InvoiceDeliverySnapshotError,
@@ -17,63 +21,46 @@ import {
} from '../invoice-deliveries'
function makeSupabase(options?: {
insertData?: Record<string, unknown> | null
insertError?: { message: string; code?: string } | null
existingData?: Record<string, unknown> | null
snapshotData?: Record<string, unknown> | null
reserveData?: string | null
reserveError?: { message: string } | null
snapshotData?: string | null
snapshotError?: { message: string } | null
terminalData?: string | null
terminalError?: { message: string } | null
manualData?: Record<string, unknown> | null
manualError?: { message: string } | null
}) {
const insertResult = {
data: options?.insertData === undefined ? { id: 'delivery-1' } : options.insertData,
error: options?.insertError ?? null,
}
const updateResults = [
{
data: options?.snapshotData === undefined ? { id: 'delivery-1' } : options.snapshotData,
error: options?.snapshotError ?? null,
},
{ data: null, error: options?.terminalError ?? null },
]
const insertSpy = vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn().mockResolvedValue(insertResult),
})),
}))
const updateSpy = vi.fn(() => {
const result = updateResults.shift() ?? { data: null, error: null }
const chain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
select: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
then: (resolve: (value: typeof result) => void) => void
} = {
eq: vi.fn(),
select: vi.fn(),
single: vi.fn().mockResolvedValue(result),
then: (resolve) => resolve(result),
const rpcSpy = vi.fn((name: string) => {
if (name === 'reserve_invoice_delivery') {
return Promise.resolve({
data: options?.reserveData === undefined ? 'delivery-1' : options.reserveData,
error: options?.reserveError ?? null,
})
}
chain.eq.mockReturnValue(chain)
chain.select.mockReturnValue(chain)
return chain
if (name === 'capture_invoice_delivery_payload') {
return Promise.resolve({
data: options?.snapshotData === undefined ? 'delivery-1' : options.snapshotData,
error: options?.snapshotError ?? null,
})
}
if (name === 'finalize_invoice_delivery') {
return Promise.resolve({
data: options?.terminalData === undefined ? 'delivery-1' : options.terminalData,
error: options?.terminalError ?? null,
})
}
return Promise.resolve({
data: options?.manualData === undefined
? { id: 'delivery-1', channel: 'manual', status: 'marked_sent' }
: options.manualData,
error: options?.manualError ?? null,
})
})
const existingResult = { data: options?.existingData ?? null, error: null }
const selectChain: Record<string, unknown> & {
eq: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
} = {
eq: vi.fn(),
maybeSingle: vi.fn().mockResolvedValue(existingResult),
}
selectChain.eq.mockReturnValue(selectChain)
const selectSpy = vi.fn(() => selectChain)
const from = vi.fn(() => ({ insert: insertSpy, update: updateSpy, select: selectSpy }))
mockCreateServiceClient.mockReturnValue({ rpc: rpcSpy })
return {
supabase: { from } as unknown as SupabaseClient,
insertSpy,
updateSpy,
supabase: {} as SupabaseClient,
rpcSpy,
}
}
@@ -87,6 +74,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
deliveryId: 'delivery-1',
to: 'customer@example.com',
cc: ['accounting@example.com'],
bcc: ['archive@example.com'],
replyTo: 'sender@example.com',
fromName: 'Example AB',
subject: 'Faktura F-1001',
@@ -97,7 +85,7 @@ function makeInput(supabase: SupabaseClient, emailService: EmailService) {
}
}
function makeEmailService(sendEmail: ReturnType<typeof vi.fn>): EmailService {
function makeEmailService(sendEmail: EmailService['sendEmail']): EmailService {
return { isConfigured: () => true, sendEmail }
}
@@ -112,7 +100,7 @@ describe('invoice delivery tracking', () => {
})
it('persists the exact payload before sending and records provider success', async () => {
const { supabase, updateSpy } = makeSupabase()
const { supabase, rpcSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: true,
provider: 'resend',
@@ -123,31 +111,40 @@ describe('invoice delivery tracking', () => {
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
status: 'pending',
to_addresses: ['customer@example.com'],
cc_addresses: ['accounting@example.com'],
subject: 'Faktura F-1001',
body_text: 'Hej!',
body_html: '<p>Hej!</p>',
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
attachment_sha256: 'sha256-exact-pdf',
}))
expect(rpcSpy).toHaveBeenNthCalledWith(
1,
'capture_invoice_delivery_payload',
expect.objectContaining({
p_to_addresses: ['customer@example.com'],
p_cc_addresses: ['accounting@example.com'],
p_bcc_addresses: ['archive@example.com'],
p_subject: 'Faktura F-1001',
p_body_text: 'Hej!',
p_body_html: '<p>Hej!</p>',
p_document_attachment_id: 'document-1',
p_attachment_filename: 'faktura-f-1001.pdf',
p_attachment_sha256: 'sha256-exact-pdf',
}),
)
expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Faktura F-1001',
text: 'Hej!',
html: '<p>Hej!</p>',
bcc: ['archive@example.com'],
attachments: [expect.objectContaining({
filename: 'faktura-f-1001.pdf',
content: Buffer.from('exact-pdf'),
})],
}))
expect(updateSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
status: 'sent',
provider: 'resend',
provider_message_id: 'provider-message-1',
}))
expect(rpcSpy).toHaveBeenNthCalledWith(
2,
'finalize_invoice_delivery',
expect.objectContaining({
p_status: 'sent',
p_provider: 'resend',
p_provider_message_id: 'provider-message-1',
}),
)
expect(result).toMatchObject({
success: true,
deliveryId: 'delivery-1',
@@ -174,7 +171,7 @@ describe('invoice delivery tracking', () => {
})
it('records a failed provider attempt without changing the saved payload', async () => {
const { supabase, updateSpy } = makeSupabase()
const { supabase, rpcSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: false,
provider: 'resend',
@@ -186,13 +183,15 @@ describe('invoice delivery tracking', () => {
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
provider: 'resend',
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
}))
expect(rpcSpy).toHaveBeenCalledWith(
'finalize_invoice_delivery',
expect.objectContaining({
p_status: 'failed',
p_provider: 'resend',
p_provider_message_id: null,
p_error_code: 'provider_failed',
}),
)
expect(mockDeleteDocument).toHaveBeenCalledWith(supabase, 'company-1', 'document-1')
expect(result.success).toBe(false)
})
@@ -208,11 +207,35 @@ describe('invoice delivery tracking', () => {
expect(result.trackingWarning).toBe('finalize_failed')
})
it('reuses an existing preparing reservation after a unique conflict', async () => {
it('treats an unexpected terminal delivery id as a finalize failure', async () => {
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
const sendEmail = vi.fn().mockResolvedValue({ success: true })
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('finalize_failed')
})
it('does not delete the archived PDF when the failed terminal id is unexpected', async () => {
const { supabase } = makeSupabase({ terminalData: 'delivery-other' })
const sendEmail = vi.fn().mockResolvedValue({
success: false,
error: 'provider rejected the request',
})
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('failure_record_failed')
expect(mockDeleteDocument).not.toHaveBeenCalled()
})
it('returns the reservation selected by the privileged RPC', async () => {
const { supabase } = makeSupabase({
insertData: null,
insertError: { message: 'duplicate', code: '23505' },
existingData: { id: 'delivery-existing' },
reserveData: 'delivery-existing',
})
await expect(reserveInvoiceDelivery({
@@ -229,7 +252,7 @@ describe('invoice delivery tracking', () => {
channel: 'manual',
status: 'marked_sent',
}
const { supabase, insertSpy } = makeSupabase({ insertData: manualDelivery })
const { supabase, rpcSpy } = makeSupabase({ manualData: manualDelivery })
await recordManualInvoiceDelivery({
supabase,
@@ -239,13 +262,11 @@ describe('invoice delivery tracking', () => {
sentAt: '2026-07-22T10:30:00.000Z',
})
expect(insertSpy).toHaveBeenCalledWith({
company_id: 'company-1',
user_id: 'user-1',
invoice_id: 'invoice-1',
channel: 'manual',
status: 'marked_sent',
sent_at: '2026-07-22T10:30:00.000Z',
expect(rpcSpy).toHaveBeenCalledWith('record_manual_invoice_delivery', {
p_company_id: 'company-1',
p_actor_user_id: 'user-1',
p_invoice_id: 'invoice-1',
p_sent_at: '2026-07-22T10:30:00.000Z',
})
})
})
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import {
InvoicePaymentAccountMissingError,
assertInvoicePaymentAccountForRender,
companyWithInvoicePaymentAccount,
hasRequiredInvoicePaymentAccount,
hasUsableInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
resolveInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { makeInvoice } from '@/tests/helpers'
import type { CompanySettings } from '@/types'
function company(overrides: Partial<CompanySettings> = {}): CompanySettings {
return {
bank_name: 'Legacy bank',
clearing_number: '1234',
account_number: '1234567',
bankgiro: '123-4567',
plusgiro: null,
swish: null,
iban: 'SE0011111111111111111111',
bic: 'LEGASESS',
...overrides,
} as CompanySettings
}
describe('invoice payment accounts', () => {
it('uses legacy payment details for SEK only', () => {
const settings = company()
expect(resolveInvoicePaymentAccount(settings, 'SEK')?.iban).toBe('SE0011111111111111111111')
expect(resolveInvoicePaymentAccount(settings, 'EUR')).toBeNull()
})
it('selects the account matching the invoice currency', () => {
const settings = company({
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR bank',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE0022222222222222222222',
bic: 'EURRSESS',
},
},
})
const rendered = companyWithInvoicePaymentAccount(settings, 'EUR')
expect(rendered.bank_name).toBe('EUR bank')
expect(rendered.iban).toBe('SE0022222222222222222222')
expect(rendered.bankgiro).toBeNull()
})
it('clears legacy SEK details when a foreign account is missing', () => {
const rendered = companyWithInvoicePaymentAccount(company(), 'EUR')
expect(rendered.iban).toBeNull()
expect(rendered.bankgiro).toBeNull()
})
it('requires an IBAN for a foreign-currency payment account', () => {
const withoutIban = resolveInvoicePaymentAccount(company({
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR bank',
clearing_number: '1234',
account_number: '1234567',
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
bic: null,
},
},
}), 'EUR')
expect(hasUsableInvoicePaymentAccount(withoutIban, 'EUR')).toBe(false)
})
it('blocks payable rendering in every currency without a usable account', () => {
const emptySettings = company({
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
})
expect(() => assertInvoicePaymentAccountForRender(company(), 'EUR')).toThrow(
InvoicePaymentAccountMissingError,
)
expect(() => assertInvoicePaymentAccountForRender(emptySettings, 'SEK')).toThrow(
InvoicePaymentAccountMissingError,
)
expect(() => assertInvoicePaymentAccountForRender(company(), 'SEK')).not.toThrow()
})
it('requires payment accounts only for payable invoice documents', () => {
expect(invoiceRequiresPaymentAccount(makeInvoice())).toBe(true)
expect(invoiceRequiresPaymentAccount(makeInvoice({ credited_invoice_id: 'invoice-original' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'delivery_note' }))).toBe(false)
expect(invoiceRequiresPaymentAccount(makeInvoice({ document_type: 'proforma' }))).toBe(false)
})
it('accepts non-payable documents without an account', () => {
const emptySettings = company({
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
})
expect(hasRequiredInvoicePaymentAccount(
emptySettings,
makeInvoice({ document_type: 'proforma' }),
)).toBe(true)
expect(hasRequiredInvoicePaymentAccount(emptySettings, makeInvoice())).toBe(false)
})
})
@@ -66,6 +66,55 @@ describe('prepareInvoicePdfRender: logo resolution (issue #772)', () => {
vi.restoreAllMocks()
})
it('renders only the payment account matching the invoice currency', async () => {
const company = makeCompanySettings({
iban: 'SE0011111111111111111111',
invoice_payment_accounts: {
EUR: {
bank_name: 'EUR Bank',
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: 'SE0022222222222222222222',
bic: 'EURRSESS',
},
},
})
const { company: resolved } = await prepareInvoicePdfRender(company, 'EUR')
expect(resolved.bank_name).toBe('EUR Bank')
expect(resolved.iban).toBe('SE0022222222222222222222')
expect(resolved.bankgiro).toBeNull()
})
it('rejects a foreign-currency PDF without a usable payment account', async () => {
const company = makeCompanySettings({ invoice_payment_accounts: {} })
await expect(prepareInvoicePdfRender(company, 'EUR')).rejects.toMatchObject({
code: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
currency: 'EUR',
})
})
it('renders non-payable foreign documents without requiring an account or leaking SEK details', async () => {
const company = makeCompanySettings({
iban: 'SE0011111111111111111111',
invoice_payment_accounts: {},
})
const { company: resolved } = await prepareInvoicePdfRender(
company,
'EUR',
{ paymentAccountRequired: false },
)
expect(resolved.iban).toBeNull()
expect(resolved.bankgiro).toBeNull()
})
it('embeds an SVG logo as a PNG data URL so @react-pdf can draw it', async () => {
const fetchMock = mockFetchOnce(SVG_LOGO, 'image/svg+xml')
const company = makeCompanySettings({
@@ -49,6 +49,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text: string
@@ -60,6 +61,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,
@@ -220,6 +222,9 @@ describe('executeRecurringSchedule auto-send', () => {
const company = makeCompanySettings({
company_name: 'Oppy Sverige',
accounting_method: 'accrual',
bankgiro: '123-4567',
invoice_email_cc_addresses: ['fixed-copy@test.se'],
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
})
function makeSchedule() {
@@ -329,7 +334,12 @@ describe('executeRecurringSchedule auto-send', () => {
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
expect.objectContaining({
companyId: 'company-1',
invoiceId: 'inv-1',
cc: ['fixed-copy@test.se'],
bcc: ['fixed-archive@test.se'],
}),
)
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
@@ -368,6 +378,50 @@ describe('executeRecurringSchedule auto-send', () => {
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('does not reserve a delivery when the customer email is blank', async () => {
const customerWithoutEmail = { ...customer, email: ' ' }
enqueue({ data: customerWithoutEmail, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({
data: { ...makeCompleteInvoice(), customer: customerWithoutEmail },
error: null,
})
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not reserve an auto-send delivery when configured recipients exceed the limit', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
enqueue({
data: {
...company,
invoice_email_cc_addresses: Array.from(
{ length: 20 },
(_, index) => `fixed-${index}@example.test`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(false)
expect(result.warning).not.toBeNull()
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockRenderToBuffer).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
mockIsSandbox.mockResolvedValue(true)
// Sandbox bails before company_settings/payment-link/render/email, so the
+155
View File
@@ -0,0 +1,155 @@
export const MAX_INVOICE_EMAIL_RECIPIENTS = 20
export const MAX_INVOICE_EMAIL_COPY_RECIPIENTS = MAX_INVOICE_EMAIL_RECIPIENTS - 1
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export interface ResolveInvoiceEmailRecipientsInput {
to: string | readonly string[]
configuredCc?: readonly string[] | null
configuredBcc?: readonly string[] | null
legacyCc?: string | null
additionalCc?: readonly string[]
additionalBcc?: readonly string[]
}
export interface ResolvedInvoiceEmailRecipients {
to: string[]
cc: string[]
bcc: string[]
}
export function invoiceEmailRecipientCount(
recipients: ResolvedInvoiceEmailRecipients,
): number {
return recipients.to.length + recipients.cc.length + recipients.bcc.length
}
export function exceedsInvoiceEmailRecipientLimit(
recipients: ResolvedInvoiceEmailRecipients,
): boolean {
return invoiceEmailRecipientCount(recipients) > MAX_INVOICE_EMAIL_RECIPIENTS
}
export interface InvoiceEmailRecipientCollision {
address: string
field: 'additional_cc' | 'additional_bcc'
conflicts_with:
| 'to'
| 'configured_cc'
| 'configured_bcc'
| 'additional_cc'
| 'additional_bcc'
}
function normalizedKey(address: string): string {
return address.trim().toLocaleLowerCase('en-US')
}
function uniqueAddresses(
addresses: readonly string[],
used: Set<string>,
): string[] {
const result: string[] = []
for (const rawAddress of addresses) {
const address = rawAddress.trim()
const key = normalizedKey(address)
if (!key || used.has(key)) continue
used.add(key)
result.push(address)
}
return result
}
/**
* Build the exact recipient lists submitted to the email provider.
*
* A null company CC list means the company has never configured the new
* setting, so the historical automatic-copy address remains in effect. An
* explicit empty list disables that fallback. Recipients are de-duplicated
* with To taking precedence over CC and CC taking precedence over BCC.
*/
export function resolveInvoiceEmailRecipients(
input: ResolveInvoiceEmailRecipientsInput,
): ResolvedInvoiceEmailRecipients {
const used = new Set<string>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
const to = uniqueAddresses(rawTo, used)
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
const cc = uniqueAddresses(
[...fixedCc, ...(input.additionalCc ?? [])],
used,
)
const bcc = uniqueAddresses(
[...(input.configuredBcc ?? []), ...(input.additionalBcc ?? [])],
used,
)
return { to, cc, bcc }
}
/**
* Report explicit per-send recipients that would be silently moved or omitted
* by deterministic To, CC, BCC precedence. Company-level configuration keeps
* its historical de-duplication behavior, while caller-supplied collisions are
* rejected before invoice number allocation so the caller can correct them.
*/
export function findAdditionalInvoiceRecipientCollisions(
input: ResolveInvoiceEmailRecipientsInput,
): InvoiceEmailRecipientCollision[] {
const occupied = new Map<string, InvoiceEmailRecipientCollision['conflicts_with']>()
const rawTo = typeof input.to === 'string' ? [input.to] : input.to
for (const address of rawTo) {
const key = normalizedKey(address)
if (key) occupied.set(key, 'to')
}
const fixedCc = input.configuredCc === null || input.configuredCc === undefined
? input.legacyCc
? [input.legacyCc]
: []
: input.configuredCc
for (const address of fixedCc) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_cc')
}
for (const address of input.configuredBcc ?? []) {
const key = normalizedKey(address)
if (key && !occupied.has(key)) occupied.set(key, 'configured_bcc')
}
const collisions: InvoiceEmailRecipientCollision[] = []
for (const address of input.additionalCc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_cc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_cc')
}
for (const address of input.additionalBcc ?? []) {
const key = normalizedKey(address)
if (!key) continue
const conflict = occupied.get(key)
if (conflict) {
collisions.push({ address: address.trim(), field: 'additional_bcc', conflicts_with: conflict })
continue
}
occupied.set(key, 'additional_bcc')
}
return collisions
}
export function parseInvoiceRecipientText(value: string): string[] {
const used = new Set<string>()
return uniqueAddresses(value.split(/[\n,;]+/), used)
}
+82 -88
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
import { deleteDocument, uploadDocument } from '@/lib/core/documents/document-service'
import { createServiceClient } from '@/lib/supabase/server'
import type { InvoiceDelivery } from '@/types'
const PDF_CONTENT_TYPE = 'application/pdf'
@@ -21,6 +22,7 @@ export interface TrackedInvoiceEmailInput {
deliveryId: string
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
replyTo?: string
fromName?: string
subject: string
@@ -44,6 +46,9 @@ function addresses(value?: string | string[]): string[] {
/**
* Persist a reusable delivery attempt before allocating an invoice number.
* The unique preparing row is also the concurrency lock for one invoice send.
* The stateless service-role client is only transport for the service-only RPC:
* the RPC re-authorizes userId as a writable member of companyId and scopes the
* invoice row to the same company before it can write anything.
*/
export async function reserveInvoiceDelivery(args: {
supabase: SupabaseClient
@@ -51,31 +56,13 @@ export async function reserveInvoiceDelivery(args: {
userId: string
invoiceId: string
}): Promise<string> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'email',
status: 'preparing',
})
.select('id')
.single()
const { data, error } = await createServiceClient().rpc('reserve_invoice_delivery', {
p_company_id: args.companyId,
p_invoice_id: args.invoiceId,
p_actor_user_id: args.userId,
})
if (data?.id) return data.id
if ((error as { code?: string } | null)?.code === '23505') {
const { data: existing, error: existingError } = await args.supabase
.from('invoice_deliveries')
.select('id')
.eq('company_id', args.companyId)
.eq('invoice_id', args.invoiceId)
.eq('status', 'preparing')
.maybeSingle()
if (!existingError && existing?.id) return existing.id
}
if (!error && typeof data === 'string') return data
throw new InvoiceDeliverySnapshotError(
`Failed to reserve invoice delivery: ${error?.message || 'unknown error'}`,
@@ -94,6 +81,7 @@ export async function sendTrackedInvoiceEmail(
deliveryId,
to,
cc,
bcc,
replyTo,
fromName,
subject,
@@ -116,30 +104,33 @@ export async function sendTrackedInvoiceEmail(
{ upload_source: 'system' },
)
const { data: delivery, error: deliveryError } = await supabase
.from('invoice_deliveries')
.update({
status: 'pending',
to_addresses: addresses(to),
cc_addresses: addresses(cc),
reply_to: replyTo || null,
from_name: fromName || null,
subject,
body_text: text,
body_html: html,
document_attachment_id: document.id,
attachment_filename: filename,
attachment_content_type: PDF_CONTENT_TYPE,
attachment_sha256: document.sha256_hash,
})
.eq('id', deliveryId)
.eq('company_id', companyId)
.eq('invoice_id', invoiceId)
.eq('status', 'preparing')
.select('*')
.single()
// These service-only RPCs re-authorize userId against companyId and bind
// every transition to the reserved invoice, so no caller-supplied tenant or
// actor identifier is trusted merely because this client bypasses RLS.
const deliveryWriter = createServiceClient()
const { data: capturedDeliveryId, error: deliveryError } = await deliveryWriter.rpc(
'capture_invoice_delivery_payload',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_invoice_id: invoiceId,
p_actor_user_id: userId,
p_to_addresses: addresses(to),
p_cc_addresses: addresses(cc),
p_bcc_addresses: addresses(bcc),
p_reply_to: replyTo || null,
p_from_name: fromName || null,
p_subject: subject,
p_body_text: text,
p_body_html: html,
p_document_attachment_id: document.id,
p_attachment_filename: filename,
p_attachment_content_type: PDF_CONTENT_TYPE,
p_attachment_sha256: document.sha256_hash,
},
)
if (deliveryError || !delivery) {
if (deliveryError || capturedDeliveryId !== deliveryId) {
try {
await deleteDocument(supabase, companyId, document.id)
} catch {
@@ -154,6 +145,7 @@ export async function sendTrackedInvoiceEmail(
const emailOptions: SendEmailOptions = {
to,
cc,
bcc,
subject,
html,
text,
@@ -170,22 +162,22 @@ export async function sendTrackedInvoiceEmail(
const result = await emailService.sendEmail(emailOptions)
if (!result.success) {
const { error: failureRecordError } = await supabase
.from('invoice_deliveries')
.update({
status: 'failed',
provider: result.provider || null,
provider_message_id: null,
error_code: 'provider_failed',
document_attachment_id: null,
failed_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
const { data: failedDeliveryId, error: failureRecordError } = await deliveryWriter.rpc(
'finalize_invoice_delivery',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_actor_user_id: userId,
p_status: 'failed',
p_provider: result.provider || null,
p_provider_message_id: null,
p_error_code: 'provider_failed',
},
)
const failureRecorded = !failureRecordError && failedDeliveryId === deliveryId
let cleanupFailed = false
if (!failureRecordError) {
if (failureRecorded) {
try {
const cleanup = await deleteDocument(supabase, companyId, document.id)
cleanupFailed = !cleanup.ok
@@ -196,9 +188,9 @@ export async function sendTrackedInvoiceEmail(
return {
...result,
deliveryId: delivery.id,
deliveryId,
documentId: document.id,
...(failureRecordError
...(!failureRecorded
? { trackingWarning: 'failure_record_failed' as const }
: cleanupFailed
? { trackingWarning: 'failure_cleanup_failed' as const }
@@ -206,23 +198,31 @@ export async function sendTrackedInvoiceEmail(
}
}
const { error: finalizeError } = await supabase
.from('invoice_deliveries')
.update({
status: 'sent',
provider: result.provider || null,
provider_message_id: result.messageId || null,
sent_at: new Date().toISOString(),
})
.eq('id', delivery.id)
.eq('company_id', companyId)
.eq('status', 'pending')
const { data: finalizedDeliveryId, error: finalizeError } = await deliveryWriter.rpc(
'finalize_invoice_delivery',
{
p_delivery_id: deliveryId,
p_company_id: companyId,
p_actor_user_id: userId,
p_status: 'sent',
p_provider: result.provider || null,
p_provider_message_id: result.messageId || null,
p_error_code: null,
},
)
// Delivery is irreversible once the provider succeeds. A failed terminal
// transition is returned as a reconciliation warning; each caller still
// advances the invoice to sent, and ordinary send routes reject non-drafts,
// so a pending evidence row never becomes permission to send a duplicate.
// Pending rows are outside the preparing-only reservation lock, so retained
// evidence also cannot block a later explicitly authorized resend.
const finalized = !finalizeError && finalizedDeliveryId === deliveryId
return {
...result,
deliveryId: delivery.id,
deliveryId,
documentId: document.id,
...(finalizeError ? { trackingWarning: 'finalize_failed' as const } : {}),
...(!finalized ? { trackingWarning: 'finalize_failed' as const } : {}),
}
}
@@ -233,18 +233,12 @@ export async function recordManualInvoiceDelivery(args: {
invoiceId: string
sentAt?: string
}): Promise<InvoiceDelivery> {
const { data, error } = await args.supabase
.from('invoice_deliveries')
.insert({
company_id: args.companyId,
user_id: args.userId,
invoice_id: args.invoiceId,
channel: 'manual',
status: 'marked_sent',
sent_at: args.sentAt || new Date().toISOString(),
})
.select('*')
.single()
const { data, error } = await createServiceClient().rpc('record_manual_invoice_delivery', {
p_company_id: args.companyId,
p_invoice_id: args.invoiceId,
p_actor_user_id: args.userId,
p_sent_at: args.sentAt || null,
})
if (error || !data) {
throw new InvoiceDeliverySnapshotError(
+144
View File
@@ -0,0 +1,144 @@
import type {
CompanySettings,
Currency,
Invoice,
InvoicePaymentAccount,
} from '@/types'
export const INVOICE_PAYMENT_ACCOUNT_CURRENCIES: readonly Currency[] = [
'SEK',
'EUR',
'USD',
'GBP',
'NOK',
'DKK',
]
const PAYMENT_FIELDS: readonly (keyof InvoicePaymentAccount)[] = [
'bank_name',
'clearing_number',
'account_number',
'bankgiro',
'plusgiro',
'swish',
'iban',
'bic',
]
function clean(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
export function legacySekInvoicePaymentAccount(
company: Pick<CompanySettings, keyof InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(company.bank_name),
clearing_number: clean(company.clearing_number),
account_number: clean(company.account_number),
bankgiro: clean(company.bankgiro),
plusgiro: clean(company.plusgiro),
swish: clean(company.swish),
iban: clean(company.iban),
bic: clean(company.bic),
}
}
export function normalizeInvoicePaymentAccount(
account: Partial<InvoicePaymentAccount>,
): InvoicePaymentAccount {
return {
bank_name: clean(account.bank_name),
clearing_number: clean(account.clearing_number),
account_number: clean(account.account_number),
bankgiro: clean(account.bankgiro),
plusgiro: clean(account.plusgiro),
swish: clean(account.swish),
iban: clean(account.iban)?.replace(/\s/g, '').toUpperCase() ?? null,
bic: clean(account.bic)?.replace(/\s/g, '').toUpperCase() ?? null,
}
}
export function resolveInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): InvoicePaymentAccount | null {
const configured = company.invoice_payment_accounts?.[currency]
if (configured) return normalizeInvoicePaymentAccount(configured)
return currency === 'SEK' ? legacySekInvoicePaymentAccount(company) : null
}
export function hasUsableInvoicePaymentAccount(
account: InvoicePaymentAccount | null,
currency: Currency,
): boolean {
if (!account) return false
if (currency !== 'SEK') return !!account.iban
return !!(
account.iban
|| account.bankgiro
|| account.plusgiro
|| account.swish
|| (account.clearing_number && account.account_number)
)
}
export function invoiceRequiresPaymentAccount(
invoice: Pick<Invoice, 'credited_invoice_id' | 'document_type'>,
): boolean {
return !invoice.credited_invoice_id
&& invoice.document_type !== 'delivery_note'
&& invoice.document_type !== 'proforma'
}
export function hasRequiredInvoicePaymentAccount(
company: CompanySettings,
invoice: Pick<Invoice, 'credited_invoice_id' | 'currency' | 'document_type'>,
): boolean {
return !invoiceRequiresPaymentAccount(invoice)
|| hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, invoice.currency),
invoice.currency,
)
}
export class InvoicePaymentAccountMissingError extends Error {
readonly code = 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING'
readonly currency: Currency
constructor(currency: Currency) {
super(`Invoice payment account is missing for ${currency}.`)
this.name = 'InvoicePaymentAccountMissingError'
this.currency = currency
}
}
export function assertInvoicePaymentAccountForRender(
company: CompanySettings,
currency: Currency,
): void {
if (
!hasUsableInvoicePaymentAccount(
resolveInvoicePaymentAccount(company, currency),
currency,
)
) {
throw new InvoicePaymentAccountMissingError(currency)
}
}
/**
* Return invoice render settings with only the matching payment account.
* Foreign invoices never inherit the legacy SEK payment details.
*/
export function companyWithInvoicePaymentAccount(
company: CompanySettings,
currency: Currency,
): CompanySettings {
const account = resolveInvoicePaymentAccount(company, currency)
const updates = Object.fromEntries(
PAYMENT_FIELDS.map((field) => [field, account?.[field] ?? null]),
) as Pick<CompanySettings, keyof InvoicePaymentAccount>
return { ...company, ...updates }
}
+22 -6
View File
@@ -20,13 +20,17 @@
*/
import QRCode from 'qrcode'
import type { CompanySettings, Invoice } from '@/types'
import type { CompanySettings, Currency, Invoice } from '@/types'
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
import {
assertInvoicePaymentAccountForRender,
companyWithInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
@@ -42,6 +46,10 @@ export interface InvoicePdfRenderExtras {
company: CompanySettings
}
export interface InvoicePdfRenderOptions {
paymentAccountRequired?: boolean
}
// A company's logo is reused across every invoice render, and twice per send
// (preflight + final render), and once per invoice in recurring/batch loops:
// so cache the re-encoded result keyed by logo URL. Only successes are cached
@@ -144,18 +152,26 @@ async function encodeLogo(logoUrl: string): Promise<string | null> {
export async function prepareInvoicePdfRender(
company: CompanySettings,
currency?: Currency,
options: InvoicePdfRenderOptions = {},
): Promise<InvoicePdfRenderExtras> {
if (currency && options.paymentAccountRequired !== false) {
assertInvoicePaymentAccountForRender(company, currency)
}
const branding = await prepareInvoiceFont(
company,
brandingFromCompanySettings(company),
)
if (!company.logo_url) return { branding, company }
const paymentCompany = currency
? companyWithInvoicePaymentAccount(company, currency)
: company
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
const dataUrl = await resolveLogoDataUrl(company.logo_url)
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
const resolved =
dataUrl && dataUrl !== company.logo_url
? { ...company, logo_url: dataUrl }
: company
dataUrl && dataUrl !== paymentCompany.logo_url
? { ...paymentCompany, logo_url: dataUrl }
: paymentCompany
return { branding, company: resolved }
}
+37 -8
View File
@@ -40,6 +40,14 @@ import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import {
exceedsInvoiceEmailRecipientLimit,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import {
hasRequiredInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { createLogger } from '@/lib/logger'
import type {
Invoice,
@@ -439,7 +447,7 @@ async function sendInvoiceFromSchedule(
})
return false
}
if (!invoice.customer.email) {
if (!invoice.customer.email?.trim()) {
log.warn('customer has no email; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
customerId: invoice.customer.id,
@@ -456,7 +464,26 @@ async function sendInvoiceFromSchedule(
if (!company) {
throw new Error('company settings missing: cannot send invoice')
}
if (!hasRequiredInvoicePaymentAccount(company, invoice)) {
log.warn('invoice currency has no usable payment account; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
currency: invoice.currency,
})
return false
}
const recipients = resolveInvoiceEmailRecipients({
to: invoice.customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
legacyCc: company.email,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
log.warn('invoice has too many email recipients; recurring schedule cannot auto-send', {
invoiceId: invoice.id,
recipientCount: invoiceEmailRecipientCount(recipients),
})
return false
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
@@ -498,8 +525,11 @@ async function sendInvoiceFromSchedule(
// Render PDF with status overridden to 'sent' so the customer doesn't
// receive a "UTKAST" stamp.
const renderableInvoice = { ...invoice, status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(company)
const swishQrDataUrl = await buildSwishQrDataUrl(company, renderableInvoice)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company,
renderableInvoice.currency,
)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -522,8 +552,6 @@ async function sendInvoiceFromSchedule(
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
})
const ccAddress = company.email || undefined
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
@@ -536,8 +564,9 @@ async function sendInvoiceFromSchedule(
userId,
invoiceId: invoice.id,
deliveryId,
to: invoice.customer.email,
cc: ccAddress,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -6,7 +6,12 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeInvoice, makeFiscalPeriod } from '@/tests/helpers'
import {
createQueuedMockSupabase,
makeCustomer,
makeInvoice,
makeFiscalPeriod,
} from '@/tests/helpers'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
@@ -54,10 +59,27 @@ vi.mock('@/lib/transactions/categorize-core', async () => {
}
})
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
})
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
isConfigured: () => true,
sendEmail: vi.fn(),
}),
}))
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: vi.fn(),
}))
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
reserveInvoiceDelivery: vi.fn().mockResolvedValue('delivery-1'),
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: vi.fn(),
}))
@@ -68,6 +90,7 @@ import { executeSIEImport } from '@/lib/import/sie-import'
import { commitAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { categorizeMatchedTransaction } from '@/lib/transactions/categorize-core'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
@@ -193,8 +216,11 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
}),
error: null,
})
enqueue({
data: { accounting_method: 'cash', entity_type: 'enskild_firma', bankgiro: '123-4567' },
error: null,
})
enqueue({ data: null, error: null }) // status update
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: null, error: null }) // dispatcher update
const op = makePendingOp({
@@ -217,6 +243,134 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
invoiceId: 'invoice-1',
})
})
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice without a payment account before number allocation',
async (currency) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
credited_invoice_id: null,
currency,
}),
error: null,
})
enqueue({ data: { invoice_payment_accounts: {} }, error: null })
enqueue({ data: null, error: null }) // dispatcher rejected update
const op = makePendingOp({
operation_type: 'mark_invoice_sent',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(mockRecordManualInvoiceDelivery).not.toHaveBeenCalled()
},
)
})
describe('commitPendingOperation: invoice send payment account guard', () => {
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice before delivery reservation and number allocation',
async (currency) => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
currency,
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
items: [],
}),
error: null,
})
enqueue({
data: {
company_name: 'Test AB',
invoice_payment_accounts: {},
},
error: null,
})
enqueue({ data: null, error: null }) // dispatcher's rejected update
const op = makePendingOp({
operation_type: 'send_invoice',
params: { invoice_id: 'invoice-1' },
})
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
op,
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
expect(supabase.from).not.toHaveBeenCalledWith('invoice_deliveries')
},
)
})
describe('commitPendingOperation: invoice send recipient limit', () => {
it('rejects an oversized configured recipient set before reservation and allocation', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: null,
customer: makeCustomer({ id: 'customer-1', email: 'customer@example.test' }),
items: [],
}),
error: null,
})
enqueue({
data: {
company_name: 'Test AB',
bankgiro: '123-4567',
invoice_email_cc_addresses: Array.from(
{ length: 20 },
(_, index) => `fixed-${index}@example.test`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
enqueue({ data: null, error: null }) // dispatcher's rejected update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
operation_type: 'send_invoice',
params: { invoice_id: 'invoice-1' },
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(ensureInvoiceNumber).not.toHaveBeenCalled()
})
})
// ─── post_annual_depreciation ───────────────────────────────────────
+63 -9
View File
@@ -73,6 +73,15 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import {
exceedsInvoiceEmailRecipientLimit,
invoiceEmailRecipientCount,
resolveInvoiceEmailRecipients,
} from '@/lib/invoices/email-recipients'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
@@ -1496,13 +1505,38 @@ async function commitSendInvoice(
}
const customer = invoice.customer as Customer
if (!customer.email) return { error: 'Customer has no email address', status: 400 }
if (!customer.email?.trim()) return { error: 'Customer has no email address', status: 400 }
const { data: company, error: companyError } = await supabase
.from('company_settings').select('*').eq('company_id', companyId).single()
if (companyError || !company) return { error: 'Company settings missing', status: 500 }
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
?? 'Betalningskonto saknas för fakturans valuta.',
status: 400,
}
}
const recipients = resolveInvoiceEmailRecipients({
to: customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
legacyCc: company.email || userEmail,
})
if (exceedsInvoiceEmailRecipientLimit(recipients)) {
return {
error:
getErrorEntry('INVOICE_SEND_TOO_MANY_RECIPIENTS')?.message_sv
?? `Ett fakturautskick får inte ha ${invoiceEmailRecipientCount(recipients)} mottagare.`,
status: 400,
}
}
const items = (invoice.items as InvoiceItem[]).sort(
(a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order
)
@@ -1523,7 +1557,11 @@ async function commitSendInvoice(
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' },
@@ -1578,8 +1616,10 @@ async function commitSendInvoice(
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 pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -1603,7 +1643,6 @@ async function commitSendInvoice(
isCreditNote,
})
const ccAddress = company.email || userEmail
const emailData = { invoice: renderableInvoice, customer, company: company as CompanySettings }
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
@@ -1617,8 +1656,9 @@ async function commitSendInvoice(
userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -1708,6 +1748,23 @@ async function commitMarkInvoiceSent(
}
if (invoice.status !== 'draft') return { error: 'Only draft invoices can be marked as sent', status: 409 }
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type, invoice_payment_accounts, bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic')
.eq('company_id', companyId)
.single()
if (settingsError || !settings) return { error: 'Company settings missing', status: 500 }
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
return {
error:
getErrorEntry('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')?.message_sv
?? 'Betalningskonto saknas för fakturans valuta.',
status: 400,
}
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
@@ -1731,9 +1788,6 @@ async function commitMarkInvoiceSent(
deliveryHistoryWarning = 'Fakturan markerades som skickad men utskickshistoriken kunde inte sparas.'
}
const { data: settings } = await supabase
.from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single()
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
@@ -593,6 +593,10 @@ describe('generateFullArchive', () => {
expect(items).toEqual([
{ id: 'item-1', invoice_id: 'inv-1', description: 'Konsulttid' },
])
expect(supabase.rpc).toHaveBeenCalledWith(
'export_invoice_delivery_evidence',
{ p_company_id: 'company-1' },
)
})
it('skips raw SIE blobs when include_documents is false but keeps metadata', async () => {
+122 -33
View File
@@ -539,28 +539,24 @@ describe('generateTrialBalance', () => {
expect(result.isBalanced).toBe(true)
})
// ── excludeYearEndClosing symmetry ───────────────────────────────
// A reversed year_end entry keeps status='reversed' and stays in the
// ledger; its storno carries source_type='storno'. Excluding on
// source_type alone drops the original but keeps the counter-entry,
// inflating the P&L by exactly the reversed amount. The filter must also
// exclude entries chained to year_end entries via reverses_id /
// correction_of_id.
// ── final-closing precision ──────────────────────────────────────
// Tax and appropriations are also source_type='year_end'. The statutory
// pre-closing report must exclude only fiscal_periods.closing_entry_id.
it('excludes stornos and corrections chained to year_end entries', async () => {
it('excludes only the fiscal period closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// 1st: the year_end entry-id fetch added for chain exclusion
{ data: [{ id: 'ye-1' }, { id: 'ye-2' }], error: null },
// 2nd: the entries step of the period-lines fetch
{ data: [{ id: 'entry-1' }], error: null },
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [
{
data: [
@@ -574,34 +570,124 @@ describe('generateTrialBalance', () => {
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeYearEndClosing: true,
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls.map((c: unknown[]) => c[0]) : []),
)
expect(orCalls).toContain('reverses_id.is.null,reverses_id.not.in.(ye-1,ye-2)')
expect(orCalls).toContain('correction_of_id.is.null,correction_of_id.not.in.(ye-1,ye-2)')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('skips the chain filters when the company has no year_end entries', async () => {
it('fails closed when a closed period has no linked final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null },
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: true,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await expect(
generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
}),
).rejects.toThrow(/missing closing_entry_id/i)
expect(supabase.from).toHaveBeenCalledTimes(1)
})
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: false,
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'tax-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
})
it('keeps a linked reversed closing together with its storno', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [{ data: [{ id: 'closing-1' }, { id: 'storno-1' }], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
await generateTrialBalance(supabase, 'company-1', 'period-1', {
excludeFinalClosingEntry: true,
})
// The OR excludes closing-1 only while status is posted. If it is
// reversed during an administrative undo, both it and its storno remain.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
})
it('preserves the broad year-end exclusion for operational reports', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2025-01-01',
period_end: '2025-12-31',
opening_balance_entry_id: null,
closing_entry_id: 'closing-1',
},
error: null,
},
],
journal_entries: [
// year_end id fetch: none exist
{ data: [], error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: [{ id: 'reversed-year-end-1' }], error: null },
{ data: [{ id: 'ordinary-1' }], error: null },
],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
@@ -613,14 +699,17 @@ describe('generateTrialBalance', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
const orCalls = builders.flatMap(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => (b.or ? b.or.mock.calls : []),
)
expect(orCalls).toHaveLength(0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
expect(orCalls).toContainEqual([
'reverses_id.is.null,reverses_id.not.in.(reversed-year-end-1)',
])
expect(orCalls).toContainEqual([
'correction_of_id.is.null,correction_of_id.not.in.(reversed-year-end-1)',
])
})
it('returns empty period activity when the range matches no lines', async () => {
+12 -3
View File
@@ -981,9 +981,17 @@ async function writeMasterData(
for (const t of MASTER_DATA_DUMP_TABLES) {
const pageKey = t.pageKey ?? 'id'
try {
const rows = t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
const rows = t.name === 'invoice_deliveries'
? await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
supabase
.rpc('export_invoice_delivery_evidence', { p_company_id: companyId })
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (row) => String(row.id) })
: t.via
? await fetchChildTableRows(supabase, companyId, t)
: await fetchAllRows<Record<string, unknown>>(({ from, to }) => {
let q = supabase.from(t.name).select('*').eq('company_id', companyId)
if (t.orderBy) {
q = q.order(t.orderBy, { ascending: true })
@@ -997,6 +1005,7 @@ async function writeMasterData(
}, { dedupeBy: (r) => String(r[pageKey]) })
data.file(t.file, JSON.stringify(rows, null, 2))
} catch (err) {
if (t.name === 'invoice_deliveries') throw err
data.file(
t.file,
JSON.stringify(
+38 -17
View File
@@ -37,6 +37,7 @@ export async function generateTrialBalance(
fiscalPeriodId: string,
options?: {
excludeYearEndClosing?: boolean
excludeFinalClosingEntry?: boolean
fromDate?: string
toDate?: string
dimensions?: Record<string, string>
@@ -51,7 +52,7 @@ export async function generateTrialBalance(
// Fetch period for opening balance computation
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
@@ -61,19 +62,23 @@ export async function generateTrialBalance(
? options.dimensions
: undefined
// Year-end exclusion must be symmetric: a reversed year_end entry stays in
// the ledger (status='reversed') together with its storno, but the storno
// carries source_type='storno' (and a correction carries 'correction'), so
// filtering on source_type alone drops the original while keeping its
// counter-entry. That inflates the P&L by exactly the reversed amount.
// Fetch the reversed year_end entry ids (company-wide: a storno may land in
// a later period than the entry it reverses) and exclude anything chained
// to them via reverses_id / correction_of_id. Only status='reversed'
// originals can be storno/correction targets (reverseEntry flips the
// original's status atomically), which keeps the id list short: in the
// common no-reversal case the chain filters are skipped entirely.
// Existing operational reports intentionally exclude every year_end entry.
// Statutory annual reports must exclude only the linked final closing entry:
// tax, depreciation, and appropriations also use source_type year_end. A
// closed period without the link is ambiguous, so fail instead of silently
// understating the statutory report.
if (
options?.excludeFinalClosingEntry
&& period?.is_closed === true
&& !period.closing_entry_id
) {
throw new Error(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
)
}
const excludeAllYearEndEntries = options?.excludeYearEndClosing
let yearEndEntryIds: string[] = []
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
yearEndEntryIds = (
await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
@@ -91,14 +96,24 @@ export async function generateTrialBalance(
let q = query.neq('source_type', 'year_end')
if (yearEndEntryIds.length > 0) {
const idList = `(${yearEndEntryIds.join(',')})`
// `.not('col','in',...)` alone would also drop NULL rows (NULL NOT IN
// (...) is NULL), i.e. every normal entry: OR in the null branch.
q = q.or(`reverses_id.is.null,reverses_id.not.in.${idList}`)
q = q.or(`correction_of_id.is.null,correction_of_id.not.in.${idList}`)
}
return q
}
const closingEntryId = options?.excludeFinalClosingEntry
? period?.closing_entry_id ?? null
: null
// The base query already admits only posted and reversed entries. Exclude a
// posted final closing entry, but retain a reversed one together with its
// storno so the two continue to net to zero. Draft entries never enter the
// base query.
const excludeClosingEntry = (query: EntryLinesQuery): EntryLinesQuery =>
closingEntryId
? query.or(`id.neq.${closingEntryId},status.neq.posted`)
: query
// ── Opening balances (IB) at period_start ──────────────────────
const { balances: obBalances, obEntryId } = await getOpeningBalances(
supabase, companyId, period
@@ -142,9 +157,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
@@ -200,9 +218,12 @@ export async function generateTrialBalance(
query = query.neq('id', obEntryId)
}
if (options?.excludeYearEndClosing) {
if (excludeAllYearEndEntries) {
query = excludeYearEndChain(query)
}
if (options?.excludeFinalClosingEntry) {
query = excludeClosingEntry(query)
}
return query
},
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { createServerClientMock, cookiesMock } = vi.hoisted(() => ({
createServerClientMock: vi.fn(),
cookiesMock: vi.fn(),
}))
vi.mock('@supabase/ssr', () => ({
createServerClient: createServerClientMock,
}))
vi.mock('next/headers', () => ({
cookies: cookiesMock,
}))
describe('createServiceClient', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://project.supabase.co')
vi.stubEnv('NEXT_PUBLIC_SUPABASE_ANON_KEY', 'anon-key')
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'service-role-key')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('uses the service-role key with a cookie-free client', async () => {
const serviceClient = { kind: 'service' }
createServerClientMock.mockReturnValue(serviceClient)
const { createServiceClient } = await import('../server')
expect(createServiceClient()).toBe(serviceClient)
expect(createServerClientMock).toHaveBeenCalledWith(
'https://project.supabase.co',
'service-role-key',
expect.objectContaining({ cookies: expect.any(Object) }),
)
const options = createServerClientMock.mock.calls[0][2] as {
cookies: { getAll: () => unknown[]; setAll: () => void }
}
expect(options.cookies.getAll()).toEqual([])
expect(() => options.cookies.setAll()).not.toThrow()
expect(cookiesMock).not.toHaveBeenCalled()
})
})
+71 -1
View File
@@ -1697,6 +1697,64 @@
"firstname_note": "The first-name placeholder uses the first word of the customer's name.",
"toast_save_failed": "Could not save"
},
"settings_invoice_email_recipients": {
"heading": "Invoice email recipients",
"description": "These recipients are added automatically to every invoice email. You can add more recipients when sending an individual invoice.",
"cc_label": "Fixed copy (CC)",
"cc_placeholder": "info@company.com",
"cc_hint": "Enter one address per line, or separate addresses with commas or semicolons.",
"bcc_label": "Fixed blind copy (BCC)",
"bcc_placeholder": "archive@company.com",
"bcc_hint": "BCC recipients are hidden from the customer and other recipients.",
"invalid_title": "Invalid email address",
"invalid_description": "Check the address {address}.",
"too_many_title": "Too many recipients",
"too_many_description": "You can enter at most {count} fixed CC and BCC addresses in total.",
"save_failed": "Could not save the recipients",
"saved_title": "Recipients saved",
"saved_description": "The fixed CC and BCC recipients will be used for the next invoice email.",
"save_failed_title": "Could not save recipients",
"saving": "Saving...",
"save": "Save recipients"
},
"settings_invoice_payment_accounts": {
"heading": "Payment accounts by currency",
"description": "The invoice automatically shows the account matching its currency. A foreign-currency account must have an IBAN before the invoice can be sent.",
"currency_tabs_label": "Configured currencies",
"add_currency_label": "Add currency",
"add_currency_placeholder": "Select currency",
"add_currency": "Add account",
"account_heading": "Payment account for {currency}",
"foreign_account_hint": "This account is only used on invoices in {currency}.",
"remove_currency": "Remove account",
"bank_label": "Bank",
"clearing_label": "Clearing number",
"account_number_label": "Account number",
"bankgiro_label": "Bankgiro",
"plusgiro_label": "Plusgiro",
"swish_label": "Swish",
"iban_label": "IBAN",
"bic_label": "BIC/SWIFT",
"required_suffix": "(required)",
"validation_title": "Check the payment account",
"validation_clearing": "The clearing number for {currency} must contain 4 to 5 digits.",
"validation_account_number": "The account number for {currency} must contain 6 to 12 digits.",
"validation_bankgiro": "The bankgiro number for {currency} is invalid.",
"validation_plusgiro": "The plusgiro number for {currency} is invalid.",
"validation_swish": "The Swish number for {currency} is invalid.",
"validation_iban": "The IBAN for {currency} is invalid.",
"validation_bic": "The BIC/SWIFT for {currency} is invalid.",
"validation_foreign_iban": "Enter an IBAN for the {currency} payment account.",
"conflict_title": "Payment accounts changed elsewhere",
"conflict_description": "Reload the latest saved values before saving. Your unsaved edits will be discarded.",
"reload_server_values": "Reload saved values",
"save_failed": "Could not save the payment accounts",
"save_failed_title": "Could not save payment accounts",
"saved_title": "Payment accounts saved",
"saved_description": "New invoices use the account matching the invoice currency.",
"saving": "Saving...",
"save": "Save payment accounts"
},
"settings_period_locking": {
"heading": "Period locking",
"locked_through_label": "Bookkeeping locked through",
@@ -2689,6 +2747,7 @@
"load_customers_failed_description": "Check your connection and try again.",
"items_card_title": "Invoice lines",
"items_card_description": "Add products or services",
"more_references": "References & more",
"description_label": "Description",
"description_placeholder": "E.g. Instagram campaign",
"quantity_label": "Quantity",
@@ -2896,6 +2955,7 @@
"delivery_manual_unknown_details": "The invoice was delivered outside Accounted, so its recipients, message, and delivered file are unknown.",
"delivery_to_label": "To",
"delivery_cc_label": "Cc",
"delivery_bcc_label": "Bcc",
"delivery_reply_to_label": "Reply to",
"delivery_from_label": "Sender name",
"delivery_subject_label": "Subject",
@@ -3200,7 +3260,17 @@
"explain_deferred": "Register without booking: the invoice is marked as sent without a voucher. Booking happens in a separate step.",
"explain_credit_cash": "Cash method: the original invoice is unpaid, so no verifikat is created when the credit note is issued.",
"explain_email": "The invoice is sent to {email}.",
"explain_manual": "The invoice is marked as sent.",
"explain_manual": "The invoice is marked as sent, but no email is sent.",
"recipient_to_label": "To",
"recipient_fixed_cc_label": "Fixed CC",
"recipient_fixed_bcc_label": "Fixed BCC",
"recipient_none": "None",
"recipient_additional_cc_label": "Additional CC for this invoice",
"recipient_additional_bcc_label": "Additional BCC for this invoice",
"recipient_additional_placeholder": "name@company.com",
"recipient_additional_hint": "Separate multiple addresses with commas or semicolons.",
"recipient_invalid": "Invalid email address: {address}",
"recipient_too_many": "An invoice email can have at most {count} recipients in total.",
"cancel": "Cancel",
"later": "Do later",
"send_invoice": "Send invoice",
+71 -1
View File
@@ -1697,6 +1697,64 @@
"firstname_note": "Platshållaren förnamn använder första ordet i kundens namn.",
"toast_save_failed": "Kunde inte spara"
},
"settings_invoice_email_recipients": {
"heading": "Mottagare vid fakturautskick",
"description": "Dessa mottagare läggs automatiskt till på varje fakturamejl. Du kan komplettera listan när en enskild faktura skickas.",
"cc_label": "Fast kopia (CC)",
"cc_placeholder": "info@foretag.se",
"cc_hint": "En adress per rad, eller separera med komma eller semikolon.",
"bcc_label": "Fast dold kopia (BCC)",
"bcc_placeholder": "arkiv@foretag.se",
"bcc_hint": "BCC-mottagare visas inte för kunden eller övriga mottagare.",
"invalid_title": "Ogiltig e-postadress",
"invalid_description": "Kontrollera adressen {address}.",
"too_many_title": "För många mottagare",
"too_many_description": "Du kan ange högst {count} fasta CC- och BCC-adresser totalt.",
"save_failed": "Kunde inte spara mottagarna",
"saved_title": "Mottagare sparade",
"saved_description": "De fasta CC- och BCC-mottagarna används vid nästa fakturautskick.",
"save_failed_title": "Kunde inte spara mottagare",
"saving": "Sparar...",
"save": "Spara mottagare"
},
"settings_invoice_payment_accounts": {
"heading": "Betalningskonton per valuta",
"description": "Fakturan visar automatiskt kontot som matchar fakturans valuta. Ett utländskt konto måste ha IBAN för att fakturan ska kunna skickas.",
"currency_tabs_label": "Konfigurerade valutor",
"add_currency_label": "Lägg till valuta",
"add_currency_placeholder": "Välj valuta",
"add_currency": "Lägg till konto",
"account_heading": "Betalningskonto för {currency}",
"foreign_account_hint": "Detta konto används bara på fakturor i {currency}.",
"remove_currency": "Ta bort konto",
"bank_label": "Bank",
"clearing_label": "Clearingnummer",
"account_number_label": "Kontonummer",
"bankgiro_label": "Bankgiro",
"plusgiro_label": "Plusgiro",
"swish_label": "Swish",
"iban_label": "IBAN",
"bic_label": "BIC/SWIFT",
"required_suffix": "(obligatoriskt)",
"validation_title": "Kontrollera betalningskontot",
"validation_clearing": "Clearingnumret för {currency} måste vara 4 till 5 siffror.",
"validation_account_number": "Kontonumret för {currency} måste vara 6 till 12 siffror.",
"validation_bankgiro": "Bankgironumret för {currency} är ogiltigt.",
"validation_plusgiro": "Plusgironumret för {currency} är ogiltigt.",
"validation_swish": "Swish-numret för {currency} är ogiltigt.",
"validation_iban": "IBAN för {currency} är ogiltigt.",
"validation_bic": "BIC/SWIFT för {currency} är ogiltigt.",
"validation_foreign_iban": "Ange IBAN för betalningskontot i {currency}.",
"conflict_title": "Betalningskontona har ändrats någon annanstans",
"conflict_description": "Läs in de senast sparade värdena innan du sparar. Dina osparade ändringar tas bort.",
"reload_server_values": "Läs in sparade värden",
"save_failed": "Kunde inte spara betalningskontona",
"save_failed_title": "Kunde inte spara betalningskonton",
"saved_title": "Betalningskonton sparade",
"saved_description": "Nya fakturor använder kontot som matchar fakturans valuta.",
"saving": "Sparar...",
"save": "Spara betalningskonton"
},
"settings_period_locking": {
"heading": "Periodlåsning",
"locked_through_label": "Bokföring låst t.o.m.",
@@ -2689,6 +2747,7 @@
"load_customers_failed_description": "Kontrollera din anslutning och försök igen.",
"items_card_title": "Fakturarader",
"items_card_description": "Lägg till produkter eller tjänster",
"more_references": "Referenser & mer",
"description_label": "Beskrivning",
"description_placeholder": "T.ex. Instagram-kampanj",
"quantity_label": "Antal",
@@ -2896,6 +2955,7 @@
"delivery_manual_unknown_details": "Utskicket gjordes utanför Accounted. Mottagare, meddelande och den levererade filen är därför inte kända.",
"delivery_to_label": "Till",
"delivery_cc_label": "Kopia",
"delivery_bcc_label": "Dold kopia",
"delivery_reply_to_label": "Svara till",
"delivery_from_label": "Avsändarnamn",
"delivery_subject_label": "Ämne",
@@ -3200,7 +3260,17 @@
"explain_deferred": "Registrera utan bokföring: fakturan markeras som skickad utan verifikation. Bokföringen görs i ett separat steg.",
"explain_credit_cash": "Kontantmetoden: originalfakturan är obetald, så ingen verifikation skapas när kreditfakturan utfärdas.",
"explain_email": "Fakturan skickas till {email}.",
"explain_manual": "Fakturan markeras som skickad.",
"explain_manual": "Fakturan markeras som skickad, men inget e-postmeddelande skickas.",
"recipient_to_label": "Till",
"recipient_fixed_cc_label": "Fast CC",
"recipient_fixed_bcc_label": "Fast BCC",
"recipient_none": "Ingen",
"recipient_additional_cc_label": "Extra CC för denna faktura",
"recipient_additional_bcc_label": "Extra BCC för denna faktura",
"recipient_additional_placeholder": "namn@foretag.se",
"recipient_additional_hint": "Separera flera adresser med komma eller semikolon.",
"recipient_invalid": "Ogiltig e-postadress: {address}",
"recipient_too_many": "Ett fakturautskick får ha högst {count} mottagare totalt.",
"cancel": "Avbryt",
"later": "Gör senare",
"send_invoice": "Skicka faktura",
@@ -0,0 +1,260 @@
-- Add configurable invoice email copies and retain the exact BCC delivery payload.
ALTER TABLE public.company_settings
ADD COLUMN invoice_email_cc_addresses text[],
ADD COLUMN invoice_email_bcc_addresses text[],
ADD CONSTRAINT company_settings_invoice_email_cc_limit
CHECK (invoice_email_cc_addresses IS NULL OR cardinality(invoice_email_cc_addresses) <= 20),
ADD CONSTRAINT company_settings_invoice_email_bcc_limit
CHECK (invoice_email_bcc_addresses IS NULL OR cardinality(invoice_email_bcc_addresses) <= 20);
ALTER TABLE public.invoice_deliveries
ADD COLUMN bcc_addresses text[] NOT NULL DEFAULT '{}';
ALTER TABLE public.invoice_deliveries
DROP CONSTRAINT invoice_deliveries_payload_shape,
ADD CONSTRAINT invoice_deliveries_payload_shape CHECK (
(
channel = 'email'
AND status = 'preparing'
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider IS NULL
AND provider_message_id IS NULL
AND error_code IS NULL
AND document_attachment_id IS NULL
AND attachment_filename IS NULL
AND attachment_content_type IS NULL
AND attachment_sha256 IS NULL
AND pii_redacted_at IS NULL
)
OR
(
channel = 'email'
AND status IN ('pending', 'sent', 'failed')
AND pii_redacted_at IS NULL
AND cardinality(to_addresses) > 0
AND subject IS NOT NULL
AND body_text IS NOT NULL
AND body_html IS NOT NULL
AND attachment_filename IS NOT NULL
AND attachment_content_type IS NOT NULL
AND attachment_sha256 IS NOT NULL
AND (
(status IN ('pending', 'sent') AND document_attachment_id IS NOT NULL)
OR status = 'failed'
)
)
OR
(
channel = 'email'
AND status IN ('sent', 'failed')
AND pii_redacted_at IS NOT NULL
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider_message_id IS NULL
AND attachment_filename IS NULL
AND attachment_sha256 IS NULL
)
OR
(
channel = 'manual'
AND status = 'marked_sent'
AND cardinality(to_addresses) = 0
AND cardinality(cc_addresses) = 0
AND cardinality(bcc_addresses) = 0
AND reply_to IS NULL
AND from_name IS NULL
AND subject IS NULL
AND body_text IS NULL
AND body_html IS NULL
AND provider IS NULL
AND provider_message_id IS NULL
AND error_code IS NULL
AND document_attachment_id IS NULL
AND attachment_filename IS NULL
AND attachment_content_type IS NULL
AND attachment_sha256 IS NULL
AND pii_redacted_at IS NULL
)
);
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
description
) VALUES (
OLD.user_id,
OLD.company_id,
'SECURITY_EVENT',
'invoice_deliveries',
OLD.id,
auth.uid(),
public.invoice_delivery_audit_state(OLD),
'Blocked deletion of immutable invoice delivery history.'
);
RETURN NULL;
END IF;
IF OLD.status = 'preparing' THEN
IF NEW.status <> 'pending'
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.provider IS NOT NULL
OR NEW.provider_message_id IS NOT NULL
OR NEW.error_code IS NOT NULL
OR NEW.sent_at IS NOT NULL
OR NEW.failed_at IS NOT NULL
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS NOT NULL
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'pending' THEN
IF NEW.status NOT IN ('sent', 'failed') THEN
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
USING ERRCODE = '23514';
END IF;
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
OR NEW.from_name IS DISTINCT FROM OLD.from_name
OR NEW.subject IS DISTINCT FROM OLD.subject
OR NEW.body_text IS DISTINCT FROM OLD.body_text
OR NEW.body_html IS DISTINCT FROM OLD.body_html
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR (
NEW.status = 'sent'
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
)
OR (
NEW.status = 'failed'
AND NEW.document_attachment_id IS NOT NULL
)
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status IN ('sent', 'failed')
AND OLD.pii_redacted_at IS NULL
AND CURRENT_DATE >= OLD.retention_expires_at
AND NEW.pii_redacted_at IS NOT NULL
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
AND NEW.status IS NOT DISTINCT FROM OLD.status
AND cardinality(NEW.to_addresses) = 0
AND cardinality(NEW.cc_addresses) = 0
AND cardinality(NEW.bcc_addresses) = 0
AND NEW.reply_to IS NULL
AND NEW.from_name IS NULL
AND NEW.subject IS NULL
AND NEW.body_text IS NULL
AND NEW.body_html IS NULL
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
AND NEW.provider_message_id IS NULL
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
AND NEW.attachment_filename IS NULL
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
AND NEW.attachment_sha256 IS NULL
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END;
$$;
CREATE OR REPLACE FUNCTION public.redact_expired_invoice_delivery_pii()
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
redacted_count integer;
BEGIN
UPDATE public.invoice_deliveries
SET to_addresses = '{}',
cc_addresses = '{}',
bcc_addresses = '{}',
reply_to = NULL,
from_name = NULL,
subject = NULL,
body_text = NULL,
body_html = NULL,
provider_message_id = NULL,
attachment_filename = NULL,
attachment_sha256 = NULL,
pii_redacted_at = now()
WHERE channel = 'email'
AND status IN ('sent', 'failed')
AND pii_redacted_at IS NULL
AND retention_expires_at <= CURRENT_DATE;
GET DIAGNOSTICS redacted_count = ROW_COUNT;
RETURN redacted_count;
END;
$$;
COMMENT ON COLUMN public.company_settings.invoice_email_cc_addresses IS
'Fixed CC recipients for invoice emails. NULL keeps the historical company-email fallback; an empty array disables it.';
COMMENT ON COLUMN public.company_settings.invoice_email_bcc_addresses IS
'Fixed BCC recipients for invoice emails.';
COMMENT ON COLUMN public.invoice_deliveries.bcc_addresses IS
'Exact BCC recipients submitted to the email provider. Redacted after the statutory retention period.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,15 @@
-- Add the legacy SEK bank fields required by currency-specific invoice accounts.
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS bank_name text,
ADD COLUMN IF NOT EXISTS clearing_number text,
ADD COLUMN IF NOT EXISTS account_number text;
COMMENT ON COLUMN public.company_settings.bank_name IS
'Legacy SEK invoice bank name mirrored from invoice_payment_accounts.SEK.';
COMMENT ON COLUMN public.company_settings.clearing_number IS
'Legacy SEK invoice clearing number mirrored from invoice_payment_accounts.SEK.';
COMMENT ON COLUMN public.company_settings.account_number IS
'Legacy SEK invoice account number mirrored from invoice_payment_accounts.SEK.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,41 @@
-- Store invoice payment instructions by settlement currency.
ALTER TABLE public.company_settings
ADD COLUMN invoice_payment_accounts jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT company_settings_invoice_payment_accounts_object
CHECK (jsonb_typeof(invoice_payment_accounts) = 'object'),
ADD CONSTRAINT company_settings_invoice_payment_account_currencies
CHECK (
invoice_payment_accounts - ARRAY['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']::text[]
= '{}'::jsonb
);
UPDATE public.company_settings
SET invoice_payment_accounts = jsonb_build_object(
'SEK',
jsonb_strip_nulls(jsonb_build_object(
'bank_name', NULLIF(trim(bank_name), ''),
'clearing_number', NULLIF(trim(clearing_number), ''),
'account_number', NULLIF(trim(account_number), ''),
'bankgiro', NULLIF(trim(bankgiro), ''),
'plusgiro', NULLIF(trim(plusgiro), ''),
'swish', NULLIF(trim(swish), ''),
'iban', NULLIF(trim(iban), ''),
'bic', NULLIF(trim(bic), '')
))
)
WHERE COALESCE(
NULLIF(trim(bank_name), ''),
NULLIF(trim(clearing_number), ''),
NULLIF(trim(account_number), ''),
NULLIF(trim(bankgiro), ''),
NULLIF(trim(plusgiro), ''),
NULLIF(trim(swish), ''),
NULLIF(trim(iban), ''),
NULLIF(trim(bic), '')
) IS NOT NULL;
COMMENT ON COLUMN public.company_settings.invoice_payment_accounts IS
'Invoice payment instructions keyed by supported invoice currency. Foreign invoices never fall back to legacy SEK details.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,661 @@
-- Keep exact invoice email payloads visible only to the sending user.
-- Other company members consume a data-minimized SECURITY DEFINER summary.
DROP POLICY IF EXISTS invoice_deliveries_select ON public.invoice_deliveries;
CREATE POLICY invoice_deliveries_select
ON public.invoice_deliveries FOR SELECT TO authenticated
USING (
company_id = public.current_active_company_id()
AND user_id = auth.uid()
);
CREATE OR REPLACE FUNCTION public.list_invoice_delivery_summaries(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS TABLE (
id uuid,
channel text,
status text,
to_addresses text[],
cc_addresses text[],
provider text,
error_code text,
document_attachment_id uuid,
sent_at timestamptz,
failed_at timestamptz,
created_at timestamptz
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT
d.id,
d.channel,
d.status,
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.to_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
ARRAY(
SELECT CASE
WHEN recipient.address ~ '^[^@]+@[^@]+$'
THEN '***@' || split_part(recipient.address, '@', 2)
ELSE '***'
END
FROM unnest(d.cc_addresses) WITH ORDINALITY AS recipient(address, position)
ORDER BY recipient.position
),
d.provider,
d.error_code,
d.document_attachment_id,
d.sent_at,
d.failed_at,
d.created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status <> 'preparing'
ORDER BY d.created_at DESC;
END;
$$;
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
'Returns active-company invoice delivery status with masked To and CC addresses. Exact payload and BCC remain server-side.';
-- Fixed CC and BCC settings can redirect every future invoice email. Keep
-- these fields owner/admin controlled even when PostgREST is called directly.
CREATE OR REPLACE FUNCTION public.enforce_invoice_email_recipient_settings_admin()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW.invoice_email_cc_addresses IS NOT DISTINCT FROM OLD.invoice_email_cc_addresses
AND NEW.invoice_email_bcc_addresses IS NOT DISTINCT FROM OLD.invoice_email_bcc_addresses
THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT'
AND NEW.invoice_email_cc_addresses IS NULL
AND NEW.invoice_email_bcc_addresses IS NULL
THEN
RETURN NEW;
END IF;
IF auth.role() = 'service_role' OR auth.uid() IS NULL THEN
RETURN NEW;
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = NEW.company_id
AND cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
) THEN
RAISE EXCEPTION 'owner or admin role required to change invoice email recipients'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS enforce_invoice_email_recipient_settings_admin
ON public.company_settings;
CREATE TRIGGER enforce_invoice_email_recipient_settings_admin
BEFORE INSERT OR UPDATE OF invoice_email_cc_addresses, invoice_email_bcc_addresses
ON public.company_settings
FOR EACH ROW
EXECUTE FUNCTION public.enforce_invoice_email_recipient_settings_admin();
-- Direct authenticated delivery writes can forge evidence. All delivery state
-- changes now go through service-role-only RPCs that bind the row to a verified
-- company member supplied by the server route.
DROP POLICY IF EXISTS invoice_deliveries_insert ON public.invoice_deliveries;
DROP POLICY IF EXISTS invoice_deliveries_update ON public.invoice_deliveries;
CREATE OR REPLACE FUNCTION public.authorize_invoice_delivery_service_actor(
p_company_id uuid,
p_actor_user_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' THEN
RAISE EXCEPTION 'invoice delivery writes require a server-controlled service role'
USING ERRCODE = '42501';
END IF;
IF p_actor_user_id IS NULL OR NOT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = p_actor_user_id
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RAISE EXCEPTION 'invoice delivery actor is not a writable company member'
USING ERRCODE = '42501';
END IF;
RETURN p_actor_user_id;
END;
$$;
REVOKE ALL ON FUNCTION public.authorize_invoice_delivery_service_actor(uuid, uuid)
FROM PUBLIC, anon, authenticated;
-- A preparing row contains no recipient or message payload. It may be removed
-- after a bounded timeout so another authorized sender can recover from a
-- crashed render without deleting accounting evidence.
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'preparing'
AND OLD.created_at <= now() - interval '15 minutes'
THEN
RETURN OLD;
END IF;
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
description
) VALUES (
OLD.user_id,
OLD.company_id,
'SECURITY_EVENT',
'invoice_deliveries',
OLD.id,
auth.uid(),
public.invoice_delivery_audit_state(OLD),
'Blocked deletion of immutable invoice delivery history.'
);
RETURN NULL;
END IF;
IF OLD.status = 'preparing' THEN
IF NEW.status <> 'pending'
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.provider IS NOT NULL
OR NEW.provider_message_id IS NOT NULL
OR NEW.error_code IS NOT NULL
OR NEW.sent_at IS NOT NULL
OR NEW.failed_at IS NOT NULL
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS NOT NULL
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status = 'pending' THEN
IF NEW.status NOT IN ('sent', 'failed') THEN
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
USING ERRCODE = '23514';
END IF;
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
OR NEW.channel IS DISTINCT FROM OLD.channel
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
OR NEW.from_name IS DISTINCT FROM OLD.from_name
OR NEW.subject IS DISTINCT FROM OLD.subject
OR NEW.body_text IS DISTINCT FROM OLD.body_text
OR NEW.body_html IS DISTINCT FROM OLD.body_html
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR (
NEW.status = 'sent'
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
)
OR (
NEW.status = 'failed'
AND NEW.document_attachment_id IS NOT NULL
)
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF OLD.status IN ('sent', 'failed')
AND OLD.pii_redacted_at IS NULL
AND CURRENT_DATE >= OLD.retention_expires_at
AND NEW.pii_redacted_at IS NOT NULL
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
AND NEW.status IS NOT DISTINCT FROM OLD.status
AND cardinality(NEW.to_addresses) = 0
AND cardinality(NEW.cc_addresses) = 0
AND cardinality(NEW.bcc_addresses) = 0
AND NEW.reply_to IS NULL
AND NEW.from_name IS NULL
AND NEW.subject IS NULL
AND NEW.body_text IS NULL
AND NEW.body_html IS NULL
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
AND NEW.provider_message_id IS NULL
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
AND NEW.attachment_filename IS NULL
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
AND NEW.attachment_sha256 IS NULL
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END;
$$;
CREATE OR REPLACE FUNCTION public.reserve_invoice_delivery(
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
delivery_id uuid;
existing_actor_id uuid;
existing_created_at timestamptz;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF NOT EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.id = p_invoice_id AND i.company_id = p_company_id
) THEN
RAISE EXCEPTION 'invoice not found for delivery reservation'
USING ERRCODE = 'P0002';
END IF;
LOOP
BEGIN
INSERT INTO public.invoice_deliveries (
company_id, user_id, invoice_id, channel, status
) VALUES (
p_company_id, actor_id, p_invoice_id, 'email', 'preparing'
)
RETURNING id INTO delivery_id;
RETURN delivery_id;
EXCEPTION WHEN unique_violation THEN
SELECT d.id, d.user_id, d.created_at
INTO delivery_id, existing_actor_id, existing_created_at
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status = 'preparing'
FOR UPDATE;
IF delivery_id IS NULL THEN
CONTINUE;
END IF;
IF existing_actor_id = actor_id THEN
RETURN delivery_id;
END IF;
IF existing_created_at > now() - interval '15 minutes' THEN
RAISE EXCEPTION 'invoice delivery is already being prepared'
USING ERRCODE = '55P03';
END IF;
DELETE FROM public.invoice_deliveries d WHERE d.id = delivery_id;
delivery_id := NULL;
END;
END LOOP;
END;
$$;
CREATE OR REPLACE FUNCTION public.capture_invoice_delivery_payload(
p_delivery_id uuid,
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid,
p_to_addresses text[],
p_cc_addresses text[],
p_bcc_addresses text[],
p_reply_to text,
p_from_name text,
p_subject text,
p_body_text text,
p_body_html text,
p_document_attachment_id uuid,
p_attachment_filename text,
p_attachment_content_type text,
p_attachment_sha256 text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
captured_id uuid;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
UPDATE public.invoice_deliveries
SET status = 'pending',
to_addresses = p_to_addresses,
cc_addresses = p_cc_addresses,
bcc_addresses = p_bcc_addresses,
reply_to = p_reply_to,
from_name = p_from_name,
subject = p_subject,
body_text = p_body_text,
body_html = p_body_html,
document_attachment_id = p_document_attachment_id,
attachment_filename = p_attachment_filename,
attachment_content_type = p_attachment_content_type,
attachment_sha256 = p_attachment_sha256
WHERE id = p_delivery_id
AND company_id = p_company_id
AND invoice_id = p_invoice_id
AND user_id = actor_id
AND status = 'preparing'
RETURNING id INTO captured_id;
IF captured_id IS NULL THEN
RAISE EXCEPTION 'invoice delivery reservation cannot capture payload'
USING ERRCODE = '23514';
END IF;
RETURN captured_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.finalize_invoice_delivery(
p_delivery_id uuid,
p_company_id uuid,
p_actor_user_id uuid,
p_status text,
p_provider text,
p_provider_message_id text,
p_error_code text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
finalized_id uuid;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF p_status = 'sent' THEN
UPDATE public.invoice_deliveries
SET status = 'sent',
provider = p_provider,
provider_message_id = p_provider_message_id,
error_code = NULL,
sent_at = now(),
failed_at = NULL
WHERE id = p_delivery_id
AND company_id = p_company_id
AND user_id = actor_id
AND status = 'pending'
RETURNING id INTO finalized_id;
ELSIF p_status = 'failed' THEN
UPDATE public.invoice_deliveries
SET status = 'failed',
provider = p_provider,
provider_message_id = NULL,
error_code = COALESCE(p_error_code, 'provider_failed'),
document_attachment_id = NULL,
sent_at = NULL,
failed_at = now()
WHERE id = p_delivery_id
AND company_id = p_company_id
AND user_id = actor_id
AND status = 'pending'
RETURNING id INTO finalized_id;
ELSE
RAISE EXCEPTION 'invoice delivery terminal status must be sent or failed'
USING ERRCODE = '22023';
END IF;
IF finalized_id IS NULL THEN
RAISE EXCEPTION 'invoice delivery cannot be finalized'
USING ERRCODE = '23514';
END IF;
RETURN finalized_id;
END;
$$;
CREATE OR REPLACE FUNCTION public.record_manual_invoice_delivery(
p_company_id uuid,
p_invoice_id uuid,
p_actor_user_id uuid,
p_sent_at timestamptz DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
actor_id uuid;
delivery public.invoice_deliveries%ROWTYPE;
BEGIN
actor_id := public.authorize_invoice_delivery_service_actor(
p_company_id,
p_actor_user_id
);
IF NOT EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.id = p_invoice_id
AND i.company_id = p_company_id
AND i.status IN ('sent', 'overdue', 'partially_paid', 'paid', 'credited')
) THEN
RAISE EXCEPTION 'invoice is not in a manually sent state'
USING ERRCODE = '23514';
END IF;
INSERT INTO public.invoice_deliveries (
company_id, user_id, invoice_id, channel, status, sent_at
) VALUES (
p_company_id, actor_id, p_invoice_id, 'manual', 'marked_sent',
COALESCE(p_sent_at, now())
)
RETURNING * INTO delivery;
RETURN to_jsonb(delivery);
END;
$$;
-- Deferred booking needs only the archived document id, not the email payload.
CREATE OR REPLACE FUNCTION public.latest_sent_invoice_delivery_document(
p_company_id uuid,
p_invoice_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
document_id uuid;
BEGIN
IF auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id AND cm.user_id = auth.uid()
)
THEN
RAISE EXCEPTION 'not authorized to find delivered invoice document'
USING ERRCODE = '42501';
END IF;
SELECT d.document_attachment_id
INTO document_id
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id
AND d.invoice_id = p_invoice_id
AND d.status = 'sent'
AND d.document_attachment_id IS NOT NULL
ORDER BY d.sent_at DESC
LIMIT 1;
RETURN document_id;
END;
$$;
-- Full statutory exports are an explicit need-to-know exception to routine
-- sender-only payload access. Authenticated callers must be owner/admin; server
-- service paths are already authorized before invoking the exporter.
CREATE OR REPLACE FUNCTION public.export_invoice_delivery_evidence(
p_company_id uuid
)
RETURNS SETOF public.invoice_deliveries
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' AND (
auth.uid() IS NULL
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
OR NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = auth.uid()
AND cm.role IN ('owner', 'admin')
)
) THEN
RAISE EXCEPTION 'owner or admin role required to export invoice delivery evidence'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
SELECT d.*
FROM public.invoice_deliveries d
WHERE d.company_id = p_company_id;
END;
$$;
REVOKE ALL ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.capture_invoice_delivery_payload(
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
uuid, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.finalize_invoice_delivery(
uuid, uuid, uuid, text, text, text, text
) FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid)
TO service_role;
GRANT EXECUTE ON FUNCTION public.capture_invoice_delivery_payload(
uuid, uuid, uuid, uuid, text[], text[], text[], text, text, text, text, text,
uuid, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.finalize_invoice_delivery(
uuid, uuid, uuid, text, text, text, text
) TO service_role;
GRANT EXECUTE ON FUNCTION public.record_manual_invoice_delivery(uuid, uuid, uuid, timestamptz)
TO service_role;
REVOKE ALL ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid)
TO authenticated, service_role;
REVOKE ALL ON FUNCTION public.export_invoice_delivery_evidence(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_invoice_delivery_evidence(uuid)
TO authenticated, service_role;
COMMENT ON FUNCTION public.reserve_invoice_delivery(uuid, uuid, uuid) IS
'Service-only reservation for an invoice email delivery. Reclaims payload-free reservations after 15 minutes.';
COMMENT ON FUNCTION public.latest_sent_invoice_delivery_document(uuid, uuid) IS
'Returns only the latest sent delivery document id for active-company deferred booking.';
COMMENT ON FUNCTION public.export_invoice_delivery_evidence(uuid) IS
'Returns exact company delivery evidence only to owner/admin audit exports and service-role server paths.';
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,25 @@
-- Repair only unambiguous final-closing links created by the canonical
-- year-end workflow. Other legacy closed periods remain unlinked so statutory
-- reporting fails explicitly instead of excluding unrelated year-end entries.
WITH exact_candidates AS (
SELECT
fp.id AS fiscal_period_id,
(array_agg(je.id))[1] AS closing_entry_id
FROM public.fiscal_periods fp
JOIN public.journal_entries je
ON je.company_id = fp.company_id
AND je.fiscal_period_id = fp.id
AND je.source_type = 'year_end'
AND je.status = 'posted'
AND je.description = 'Årsbokslut ' || fp.name
WHERE fp.is_closed = true
AND fp.closing_entry_id IS NULL
GROUP BY fp.id
HAVING count(*) = 1
)
UPDATE public.fiscal_periods fp
SET closing_entry_id = candidate.closing_entry_id
FROM exact_candidates candidate
WHERE fp.id = candidate.fiscal_period_id
AND fp.closing_entry_id IS NULL;
+20
View File
@@ -171,6 +171,17 @@ export type BankConnectionStatus = 'pending' | 'pending_selection' | 'active' |
// Currency types
export type Currency = 'SEK' | 'EUR' | 'USD' | 'GBP' | 'NOK' | 'DKK'
export interface InvoicePaymentAccount {
bank_name: string | null
clearing_number: string | null
account_number: string | null
bankgiro: string | null
plusgiro: string | null
swish: string | null
iban: string | null
bic: string | null
}
// Profile (extends auth.users)
export interface Profile {
id: string
@@ -282,6 +293,9 @@ export interface CompanySettings {
swish: string | null
iban: string | null
bic: string | null
// Invoice payment instructions keyed by the currency they can receive.
// Legacy bank fields above remain the SEK fallback for older companies.
invoice_payment_accounts?: Partial<Record<Currency, InvoicePaymentAccount>>
// Accounting method
accounting_method: AccountingMethod
@@ -352,6 +366,11 @@ export interface CompanySettings {
// Editable invoice email texts. null = all defaults.
invoice_email_texts: InvoiceEmailTexts | null
// Fixed invoice-email recipients. null means the company has not configured
// the setting yet and keeps the historical automatic CC fallback. [] is an
// explicit choice to send no copies.
invoice_email_cc_addresses?: string[] | null
invoice_email_bcc_addresses?: string[] | null
// Automation
send_invoice_reminders: boolean
@@ -1009,6 +1028,7 @@ export interface InvoiceDelivery {
status: InvoiceDeliveryStatus
to_addresses: string[]
cc_addresses: string[]
bcc_addresses: string[]
reply_to: string | null
from_name: string | null
subject: string | null