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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
+37
@@ -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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 «Markera som skickad».
|
||||
</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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) ?? []
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export interface SendEmailOptions {
|
||||
to: string | string[]
|
||||
cc?: string | string[]
|
||||
bcc?: string | string[]
|
||||
subject: string
|
||||
html: string
|
||||
text?: string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user