Fix/usr fdbck ch (#1105)

* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
This commit is contained in:
Mattsson
2026-07-22 18:49:57 +02:00
committed by GitHub
parent 3e1ea29d02
commit 321e684523
58 changed files with 3742 additions and 285 deletions
@@ -23,3 +23,14 @@ deleted because issued invoice lines, archived invoice PDFs, journal entries,
and audit events retain the accounting evidence independently. Any article that
is referenced by an invoice line is protected by the application check and the
database foreign key.
## Invoice delivery history
Invoice recipient addresses, subjects, and message bodies are Confidential
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.
+7
View File
@@ -41,6 +41,7 @@ the company can read and write them, subject to their role. This includes:
- Customers and suppliers
- Receipts and documents
- **Bank connections (Enable Banking PSD2)**
- Invoice delivery metadata and archived sent PDFs
- Mapping rules, booking templates, counterparty templates
- Salary runs and AGI declarations
- Company settings
@@ -51,6 +52,12 @@ restrict *which records* they can act on. A viewer cannot post any journal
entry; a member can post any journal entry their company owns, regardless
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.
### Why this is intentional
Accounted's users are small businesses and the bookkeepers / consultants they
@@ -0,0 +1,44 @@
# DPIA screening: invoice delivery history
Date: 2026-07-22
Owner: Accounted controller
Status: Screening completed
## Processing
The service records each customer-invoice delivery attempt, its recipient and
message payload, delivery result, and the exact attached PDF. The purpose is to
provide operational delivery history and evidence that accounting information
was sent. The lawful bases are contract performance under GDPR Article 6(1)(b)
and legal obligations under Article 6(1)(c) and BFL 7 kap.
## Necessity and proportionality
The exact payload is needed server-side to resolve delivery disputes and retain
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.
## Risks and controls
- Cross-tenant disclosure: route context, explicit `company_id` filters, RLS,
and active-company document authorization.
- Excess browser disclosure: allow-listed response fields, domain masking, and
`private, no-store` caching.
- 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
redaction after the statutory minimum expires.
- Misleading failed evidence: a provider failure detaches and deletes the
unsent archived PDF while retaining attempt metadata.
## Screening conclusion
The processing is limited to ordinary invoice contact and communication data,
does not involve systematic monitoring, special-category data, automated legal
decisions, or large-scale combination of datasets. With the controls above it
does not meet the GDPR Article 35 high-risk threshold, so a full DPIA is not
required. Re-screen before adding message search, analytics, special-category
content, or cross-customer profiling.
+45
View File
@@ -8,6 +8,51 @@
processing_activities:
- id: invoices.delivery_history
name: Leveranshistorik för kundfakturor
purpose: >-
Dokumentera att en kundfaktura skickades, när leveransförsöket skedde,
vilken mottagardomän som användes och vilken exakt PDF som skickades.
Dashboardens listvy visar endast maskerade mottagardomäner och
driftmetadata. Fullständigt meddelandeinnehåll stannar på serversidan.
lawful_basis: art_6_1_b_and_c
special_category_basis: null
controller: gnubok-tenant
processor: supabase_and_resend
data_subjects:
- customer_contact
- company_member
data_categories:
- user.contact.email
- user.communication_content
- user.activity_timestamp
- user.document
recipients:
- name: Supabase
country: EU
role: processor
- name: Resend
country: US
role: processor
international_transfers:
applicable: true
mechanism: scc_2021_c2p
note: Resend processes the outbound delivery under SCC Module 2.
retention:
duration: through_seventh_calendar_year_after_fiscal_year_end
basis: bfl_7_kap_and_gdpr_storage_limitation
stored_in:
- invoice_deliveries
- document_attachments
security_measures:
- active_company_route_context
- rls_company_scoped
- masked_recipient_domains_in_list_api
- no_message_content_in_browser_list_api
- exact_sent_pdf_worm_protection
- metadata_only_immutable_audit_log
- daily_post_retention_pii_redaction
- id: customer.private_identity
name: Personnummer för privatkund
purpose: >-
+7
View File
@@ -271,8 +271,15 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-21] Keep live annual-report narrative editable after a version is locked: immutable version snapshots preserve signing and filing content, while edits must remain possible to create a corrected superseding version.
[2026-07-21] Restrict annual-report signature evidence transitions to the server service role and structured opaque references: browser RLS may manage only unbound pending roster rows, so route validation cannot be bypassed and evidence references cannot carry free-text personal data.
[2026-07-21] Card-descriptor normalization keys on the pre-star merchant segment (post-star for processor prefixes) plus a token_subset match tier, instead of the deferred AI descriptor normalization (data_quality_master Appendix B): deterministic, mirrors into normalize_counterparty_key() so ledger-context template joins stay exact, and fixes the reported Anthropic no-signal case with no new infrastructure. Merchant history now falls back to description because card purchases never carry merchant_name.
[2026-07-22] Use descriptive invoice PDF filenames across downloads, emails, archives, and recurring sends, and omit exact-zero booking rows created by informational invoice text: users get consistent documents and previews contain only accounting-relevant lines.
[2026-07-22] Long uploaded filenames use a responsive middle ellipsis with a preserved 16-character tail: users can still see the unique suffix and extension without the filename widening its dialog.
[2026-07-22] Record each invoice delivery as an immutable attempt with its exact email payload and archived PDF, and do not backfill legacy sent invoices from updated_at or the 30-day event log: an honest missing-history state is safer than fabricated delivery evidence.
[2026-07-22] Omit DELETE RLS and the generic audit trigger from invoice_deliveries: a database trigger must block deletion even for privileged paths, while copying the full immutable email payload into audit_log would duplicate recipient PII without adding evidence.
[2026-07-22] Apply only 20260722101319_invoice_deliveries to Supabase staging from an isolated CLI workdir after a one-migration dry run: the repo and applied SQL matched SHA-256 7B9A958E4A941291CCFDD94B4889CE4485491BBB3F3CEEBBA69F4961C51BEFD5, and migration history confirmed the version.
[2026-07-22] Reserve invoice delivery history before allocating an invoice number, expose only active-company masked metadata, redact PII after the BFL retention date, and audit metadata only: this preserves exact sent evidence without duplicating recipient content; migration 20260722150000 was applied only to Supabase staging from byte-identical SHA-256 5057E28A18E618CB73781506FF6AF29CB000F94C08438FC006C11B9FA5E61328.
[2026-07-22] Issue #313 fix limited to the meals warning; left "Representationsgåvor max 180 kr" on the gåvor line untouched: scope rule (only the inverted-VAT claim and repealed ML 8:9 reference), even though the swedish-vat skill lists 300 SEK as the representationsgåvor base; flagged as follow-up in the PR.
[2026-07-22] LEGACY_DISCOVERY_HOSTS drift guard (#1093) is an exported validateLegacyDiscoveryHosts() returning a violations list, exercised only by a unit test that pins the registered prod config (app.accounted.se canonical + app.gnubok.se SKV pin), not a startup assertion: CI does not set the prod env vars, so a runtime assertion would either no-op in CI or crash self-hosted deploys with different domains; the test-pinned constants make any allowlist or pin change a deliberate, reviewed edit.
[2026-07-22] Include invoice_deliveries in the full archive master-data dump rather than marking it covered by archived documents: the delivery row carries recipient, status, and timestamp evidence that the PDF manifest cannot reconstruct.
[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.
+49 -6
View File
@@ -18,6 +18,7 @@ import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode'
import { canCopyInvoice } from '@/lib/invoices/copy-invoice'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import {
Loader2,
ArrowLeft,
@@ -43,6 +44,10 @@ import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
InvoiceDeliveryHistory,
type InvoiceDeliveryView,
} from '@/components/invoices/InvoiceDeliveryHistory'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import {
Dialog,
@@ -75,7 +80,6 @@ const accrualMonth = (date: string): string => date.slice(0, 7)
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
sent_at?: string
// Optional reference to the issuance verifikation. Populated by the
// backend when the invoice flow auto-books an entry on send; absent on
// older invoices and on companies where issuance is not auto-booked.
@@ -94,6 +98,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
const [deliveries, setDeliveries] = useState<InvoiceDeliveryView[]>([])
// Payment history backing the new Betalningsstatus card. Fetched alongside
// the invoice itself so the card stays in sync with paid_amount /
// remaining_amount on the invoice row.
@@ -150,9 +155,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
.maybeSingle()
: Promise.resolve(null)
// Invoice, reminders, and payments all key on the route id — one
const deliveriesPromise = fetch(`/api/invoices/${encodeURIComponent(id)}/deliveries`)
.then(async (response) => {
if (!response.ok) return []
const payload = (await response.json()) as { data?: InvoiceDeliveryView[] }
return Array.isArray(payload.data) ? payload.data : []
})
.catch(() => [] as InvoiceDeliveryView[])
// Invoice, reminders, payments, and deliveries all key on the route id: one
// parallel batch. Only the follow-ups below need the invoice row.
const [{ data, error }, { data: reminderData }, { data: paymentData }] =
const [{ data, error }, { data: reminderData }, { data: paymentData }, deliveryData] =
await Promise.all([
supabase
.from('invoices')
@@ -179,6 +192,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
.eq('invoice_id', id)
.order('payment_date', { ascending: true }),
deliveriesPromise,
])
if (error || !data) {
@@ -197,6 +211,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
setInvoice(data as InvoiceWithRelations)
setDeliveries(deliveryData)
if (reminderData) {
setReminders(reminderData as InvoiceReminder[])
@@ -410,7 +425,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsDownloading(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/pdf`)
const mostRecentDelivery = deliveries.find(
(delivery) => delivery.status === 'sent' || delivery.status === 'marked_sent',
)
const archivedDelivery = mostRecentDelivery?.status === 'sent'
? mostRecentDelivery
: undefined
const response = await fetch(
archivedDelivery?.document_attachment_id
? `/api/documents/${archivedDelivery.document_attachment_id}/inline`
: `/api/invoices/${invoice.id}/pdf`,
)
if (!response.ok) {
throw new Error(t('pdf_generate_failed'))
@@ -420,7 +445,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `faktura-${invoice.invoice_number ?? `utkast-${invoice.id.slice(0, 8)}`}.pdf`
a.download = contentDispositionFilename(response.headers.get('Content-Disposition'))
?? `faktura-${invoice.invoice_number ?? `utkast-${invoice.id.slice(0, 8)}`}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
@@ -616,6 +642,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const isEditableDraft = isEditableInvoiceDraft(invoice)
const isCopyable = canCopyInvoice(invoice)
const hasAccruedItems = invoice.items.some(itemHasAccrual)
const latestCompletedDelivery = deliveries.find(
(delivery) => delivery.status === 'sent' || delivery.status === 'marked_sent',
)
return (
<div className="space-y-8">
{/* Header */}
@@ -648,7 +677,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
<p className="text-muted-foreground">
{t('created_at', { date: formatDate(invoice.created_at) })}
{invoice.sent_at && t('sent_at_suffix', { date: formatDate(invoice.sent_at) })}
{latestCompletedDelivery?.sent_at &&
t('sent_at_suffix', { date: formatDate(latestCompletedDelivery.sent_at) })}
</p>
</div>
</div>
@@ -972,6 +1002,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Card>
)}
{isRealInvoice && !isSelfBilled && (
<InvoiceDeliveryHistory
deliveries={deliveries}
showLegacyEmptyState={[
'sent',
'paid',
'partially_paid',
'overdue',
'credited',
].includes(invoice.status)}
/>
)}
{/* Sidebar */}
<div className="lg:col-start-3 lg:row-start-1 lg:row-span-3 space-y-6">
{/* Invoice details */}
+41 -9
View File
@@ -39,7 +39,12 @@ import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type {
TransactionWithInvoice,
ViewMode,
CategorizeHandler,
SourceFilter,
} from '@/components/transactions/transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
@@ -98,6 +103,12 @@ const TemplatePicker = dynamic(() => import('@/components/transactions/TemplateP
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
const SOURCE_FILTER_STORAGE_KEY = 'Accounted:transaction-source-filter:v1'
function isSourceFilter(value: string | null): value is SourceFilter {
return value === 'all' || value === 'bank' || value === 'skatteverket'
}
function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record<string, InvoiceWithCustomer> {
if (!rows) return {}
return rows.reduce<Record<string, InvoiceWithCustomer>>((acc, inv) => {
@@ -304,9 +315,27 @@ export default function TransactionsPage() {
// ever visit the settings panel where the reconnect prompt lives.
const [skvNeedsReconnect, setSkvNeedsReconnect] = useState(false)
// Source filter for the merged inbox. Defaults to 'all' so users see
// both sources unless they want to narrow down.
const [sourceFilter, setSourceFilter] = useState<'all' | 'bank' | 'skatteverket'>('all')
// One browser-wide source filter shared by the inbox and history views.
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
useEffect(() => {
try {
const stored = window.localStorage.getItem(SOURCE_FILTER_STORAGE_KEY)
if (isSourceFilter(stored)) setSourceFilter(stored)
} catch {
// localStorage may be unavailable. Keep the default in-memory state.
}
}, [])
const handleSourceFilterChange = useCallback((next: SourceFilter) => {
setSourceFilter(next)
try {
window.localStorage.setItem(SOURCE_FILTER_STORAGE_KEY, next)
} catch {
// localStorage may be unavailable. The in-memory filter still works.
}
}, [])
const { toast } = useToast()
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
@@ -2204,14 +2233,15 @@ export default function TransactionsPage() {
))}
</DataList>
) : mode === 'inbox' ? (
inboxItems.length === 0 && !searchTerm ? (
inboxItems.length === 0 && !searchTerm && sourceFilter === 'all' ? (
<InboxZeroState
hasTransactions={transactions.length > 0 || skvRows.length > 0}
onCreateTransaction={() => setIsDialogOpen(true)}
/>
) : (
<DataList>
{skvUnmatched.length > 0 && uncategorizedTransactions.length > 0 && (
{(sourceFilter !== 'all'
|| (skvUnmatched.length > 0 && uncategorizedTransactions.length > 0)) && (
<DataListHeader>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{t('source_label')}
@@ -2230,7 +2260,7 @@ export default function TransactionsPage() {
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as typeof sourceFilter)}
onValueChange={(v) => handleSourceFilterChange(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">
{t('source_all', { count: uncategorizedTransactions.length + skvUnmatched.length })}
@@ -2246,10 +2276,10 @@ export default function TransactionsPage() {
</DropdownMenu>
</DataListHeader>
)}
{inboxItems.length === 0 && searchTerm ? (
{inboxItems.length === 0 && (searchTerm || sourceFilter !== 'all') ? (
<DataListEmpty
title="Inga träffar"
description={t('no_search_results')}
description={searchTerm ? t('no_search_results') : t('source_empty')}
/>
) : null}
<AnimatePresence mode="popLayout">
@@ -2294,6 +2324,8 @@ export default function TransactionsPage() {
transactions={transactions}
skvRows={skvRows}
searchTerm={searchTerm}
sourceFilter={sourceFilter}
onSourceFilterChange={handleSourceFilterChange}
jeUnderlagStatus={jeUnderlagStatus}
onOpenMatchDialog={openMatchDialog}
onOpenCategoryDialog={openCategoryDialog}
+7 -1
View File
@@ -19,7 +19,7 @@ export default function PrivacyPolicyPage() {
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-06-03
Senast uppdaterad: 2026-07-22
</p>
</div>
@@ -49,6 +49,7 @@ export default function PrivacyPolicyPage() {
<li><strong>Bokföringsdata:</strong> Verifikationer, fakturor, kvitton, transaktioner, kontoplaner</li>
<li><strong>Bankdata:</strong> Kontosaldon och transaktioner (via PSD2-koppling)</li>
<li><strong>Dokument:</strong> Uppladdade kvitton, fakturor och andra bokföringsunderlag</li>
<li><strong>Fakturaleverans:</strong> Mottagaradress, leveransstatus, tidpunkt och innehållet i skickade fakturamejl</li>
<li><strong>Tekniska uppgifter:</strong> IP-adress, enhetstyp, användningsstatistik</li>
</ul>
</CardContent>
@@ -200,6 +201,11 @@ export default function PrivacyPolicyPage() {
med Bokföringslagen (BFL) 7 kap. 2 §. Systemet hindrar radering av material
kopplat till bokförda verifikationer under denna period.
</li>
<li>
<strong>Fakturaleverans:</strong> Leveransbevis och den skickade PDF-filen bevaras
under bokföringslagens lagringstid. Därefter raderas mottagaradresser,
meddelandeinnehåll och andra personuppgifter automatiskt från leveranshistoriken.
</li>
<li>
<strong>Kontouppgifter:</strong> länge kontot är aktivt, plus 30 dagar efter
begäran om radering (för att hantera pågående bokföringsplikter).
@@ -12,6 +12,14 @@ vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const downloadMock = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
@@ -73,9 +81,8 @@ describe('GET /api/documents/[id]/inline', () => {
expect(body.error).toBe('Document not found')
})
it('returns 404 when the user is not a member of the document company', async () => {
enqueue({ data: makeDoc(), error: null }) // doc lookup
enqueue({ data: null, error: null }) // membership lookup
it('returns 404 when the document is outside the active company', async () => {
enqueue({ data: null, error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
@@ -83,7 +90,6 @@ describe('GET /api/documents/[id]/inline', () => {
it('returns 500 when the storage download fails', async () => {
enqueue({ data: makeDoc(), error: null })
enqueue({ data: { company_id: 'company-1' }, error: null })
downloadMock.mockResolvedValue({ data: null, error: { message: 'boom' } })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
const { status } = await parseJsonResponse(res)
@@ -92,7 +98,6 @@ describe('GET /api/documents/[id]/inline', () => {
it('streams the file with an RFC 5987 Content-Disposition for an NFD filename', async () => {
enqueue({ data: makeDoc(), error: null })
enqueue({ data: { company_id: 'company-1' }, error: null })
const res = await GET(makeReq(), createMockRouteParams({ id: 'doc-1' }))
@@ -104,5 +109,6 @@ describe('GET /api/documents/[id]/inline', () => {
// ASCII fallback replaces the non-ASCII character.
expect(disposition).toContain('filename="kvitto f_rvaring.pdf"')
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
})
})
+47 -59
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { contentDisposition } from '@/lib/api/content-disposition'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
@@ -43,66 +43,54 @@ function resolveContentType(fileName: string, dbMimeType: string | null): string
const ext = fileName.toLowerCase().split('.').pop() ?? ''
return EXTENSION_MIME_MAP[ext] ?? dbMimeType ?? 'application/octet-stream'
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { user, supabase, error } = await requireAuth()
if (error) return error
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'document.inline',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { id } = await params
// Authorize via the auth-bound client and the active tenant. RLS remains
// the second layer, while the explicit company filter prevents a document
// from another membership being opened through a guessed identifier.
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, file_name, mime_type, storage_path')
.eq('id', id)
.eq('company_id', companyId)
.single()
// Authorize via the auth-bound client: RLS + explicit company filter
// through user_company_ids (defense in depth).
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, file_name, mime_type, storage_path')
.eq('id', id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Use the service-role client to read from the non-public bucket only after
// the active-company authorization check above has succeeded.
const serviceClient = createServiceClient()
const { data: blob, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
// Explicit membership check on top of RLS.
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', doc.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 },
)
}
if (!membership) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Use the service-role client to read from the non-public bucket.
const serviceClient = createServiceClient()
const { data: blob, error: downloadError } = await serviceClient.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !blob) {
return NextResponse.json(
{ error: `Failed to download document: ${getUserErrorMessage(downloadError) ?? 'unknown error'}` },
{ status: 500 }
)
}
return new NextResponse(blob, {
status: 200,
headers: {
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
// RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain
// combining marks (> 0xFF), which undici Headers reject as non-
// ByteString values; splicing the raw name here 500ed the route.
'Content-Disposition': contentDisposition('inline', doc.file_name),
'Cache-Control': 'private, max-age=300',
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
'X-Content-Type-Options': 'nosniff',
},
})
}
return new NextResponse(blob, {
status: 200,
headers: {
'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
// RFC 5987 dual form: NFD filenames from macOS/iOS uploads contain
// combining marks (> 0xFF), which undici Headers reject as non-
// ByteString values; splicing the raw name here 500ed the route.
'Content-Disposition': contentDisposition('inline', doc.file_name),
'Cache-Control': 'private, no-store',
// Block MIME sniffing: Content-Type is derived from DB metadata
// (with extension fallback for legacy rows), never from response
// content. Without nosniff a tampered file_name extension could
// serve a stored document under an attacker-chosen MIME type.
'X-Content-Type-Options': 'nosniff',
},
})
},
)
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
}))
const mockLinkToJournalEntry = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -68,6 +73,7 @@ describe('POST /api/invoices/[id]/book', () => {
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
mockCreateSchedules.mockResolvedValue({ created: 0, failed: 0 })
mockLinkToJournalEntry.mockResolvedValue({ id: 'document-1' })
})
it('returns 401 when not authenticated', async () => {
@@ -169,6 +175,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: null, error: null })
const { status, body } = await parseJsonResponse<{
data: { journal_entry_id: string }
@@ -188,4 +195,23 @@ describe('POST /api/invoices/[id]/book', () => {
)
expect(mockCreateSchedules).toHaveBeenCalled()
})
it('links the delivered PDF to the deferred journal entry', async () => {
const invoice = makeUnbookedInvoice()
enqueue({ data: invoice, error: null })
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 })
const { status } = await parseJsonResponse(await bookRequest())
expect(status).toBe(200)
expect(mockLinkToJournalEntry).toHaveBeenCalledWith(
mockSupabase,
'company-1',
'document-1',
'je-1',
)
})
})
+44 -1
View File
@@ -5,6 +5,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import type { CompanySettings, EntityType, Invoice, InvoiceItem } from '@/types'
// Statuses where the revenue entry can still be created afterwards. Paid
@@ -121,11 +122,53 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_BOOK_CONFLICT', log, { requestId })
}
const warnings: Array<{ code: string; message: string }> = []
// 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()
if (deliveryDocumentError) {
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
invoiceId: id,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
} else if (deliveryDocument?.document_attachment_id) {
try {
await linkToJournalEntry(
supabase,
companyId!,
deliveryDocument.document_attachment_id,
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,
})
warnings.push({
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
}
}
// Periodiseringar ride on the revenue entry, so they can only be created
// now. Non-blocking: the entry is committed (immutable); a schedule
// failure is surfaced as a warning and retried from the periodiseringar
// page.
const warnings: Array<{ code: string; message: string }> = []
try {
const accrual = await createSchedulesForCustomerInvoice(
supabase,
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET } from '../route'
const INVOICE_ID = '550e8400-e29b-41d4-a716-446655440000'
describe('GET /api/invoices/[id]/deliveries', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1', email: 'user@example.com' } },
})
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET(
createMockRequest('/api/invoices/invoice-1/deliveries'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(401)
})
it('returns 400 when the invoice id is invalid', async () => {
const response = await GET(
createMockRequest('/api/invoices/not-a-uuid/deliveries'),
createMockRouteParams({ id: 'not-a-uuid' }),
)
expect(response.status).toBe(400)
})
it('returns 404 when the invoice is outside the active company', async () => {
enqueue({ data: null, error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
expect(response.status).toBe(404)
})
it('returns minimized delivery metadata with masked recipient domains', async () => {
const delivery = {
id: 'delivery-1',
channel: 'email',
status: 'sent',
to_addresses: ['customer@example.com'],
cc_addresses: [],
reply_to: 'sender@example.com',
from_name: 'Example AB',
subject: 'Faktura F-1001',
body_text: 'Hej! Här kommer fakturan.',
provider: 'resend',
provider_message_id: 'provider-1',
error_code: null,
document_attachment_id: 'document-1',
attachment_filename: 'faktura-f-1001.pdf',
attachment_content_type: 'application/pdf',
attachment_sha256: 'abc123',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}
enqueue({ data: { id: INVOICE_ID }, error: null })
enqueue({ data: [delivery], error: null })
const response = await GET(
createMockRequest(`/api/invoices/${INVOICE_ID}/deliveries`),
createMockRouteParams({ id: INVOICE_ID }),
)
const { body } = await parseJsonResponse<{ data: Array<Record<string, unknown>> }>(response)
expect(response.status).toBe(200)
expect(body.data).toEqual([{
id: 'delivery-1',
channel: 'email',
status: 'sent',
to_addresses: ['***@example.com'],
cc_addresses: [],
provider: 'resend',
error_code: null,
document_attachment_id: 'document-1',
sent_at: '2026-07-22T10:30:00.000Z',
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}])
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')
})
})
+102
View File
@@ -0,0 +1,102 @@
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'
type DeliveryListRow = Pick<
InvoiceDelivery,
| 'id'
| 'channel'
| 'status'
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'sent_at'
| 'failed_at'
| 'created_at'
>
const DELIVERY_COLUMNS = [
'id',
'channel',
'status',
'to_addresses',
'cc_addresses',
'provider',
'error_code',
'document_attachment_id',
'sent_at',
'failed_at',
'created_at',
].join(', ')
function maskRecipientDomain(address: string): string {
const separator = address.lastIndexOf('@')
if (separator <= 0 || separator === address.length - 1) return '***'
return `***@${address.slice(separator + 1)}`
}
/**
* 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.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
async (_request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
if (!z.string().uuid().safeParse(id).success) {
return errorResponseFromCode('VALIDATION_ERROR', log, {
requestId,
details: { field: 'id', message: 'Invoice id must be a UUID.' },
})
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (invoiceError || !invoice) {
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 })
if (error) {
log.error('failed to list invoice deliveries', error, { invoiceId: id })
throw error
}
const minimized = ((deliveries || []) as unknown as DeliveryListRow[]).map((delivery) => ({
id: delivery.id,
channel: delivery.channel,
status: delivery.status,
to_addresses: delivery.to_addresses.map(maskRecipientDomain),
cc_addresses: delivery.cc_addresses.map(maskRecipientDomain),
provider: delivery.provider,
error_code: delivery.error_code,
document_attachment_id: delivery.document_attachment_id,
sent_at: delivery.sent_at,
failed_at: delivery.failed_at,
created_at: delivery.created_at,
}))
return NextResponse.json(
{ data: minimized },
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
)
@@ -73,6 +73,11 @@ vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
const mockRecordManualInvoiceDelivery = vi.fn()
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
@@ -117,6 +122,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
journalEntryRequired: true,
failures: [],
})
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
})
it('returns 401 when not authenticated', async () => {
@@ -175,6 +181,12 @@ 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(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: mockSupabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'inv-1',
})
expect(mockRenderToBuffer).toHaveBeenCalledTimes(1)
expect(mockUploadDocument).toHaveBeenCalledTimes(1)
@@ -183,7 +195,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
'user-1',
'company-1',
expect.objectContaining({
name: 'faktura-F-2026010.pdf',
name: 'Test Firma x Test AB Faktura nr F-2026010 20240615.pdf',
type: 'application/pdf',
}),
expect.objectContaining({
@@ -290,7 +302,9 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ name: 'kreditfaktura-KR-F-2026010.pdf' }),
expect.objectContaining({
name: 'Test Firma x Test AB Kreditfaktura nr KR-F-2026010 20240615.pdf',
}),
expect.anything()
)
})
+28 -3
View File
@@ -13,8 +13,10 @@ import {
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
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 { uploadDocument } from '@/lib/core/documents/document-service'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
@@ -371,9 +373,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
)
const filename = invoice.credited_invoice_id
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
const filename = invoicePdfFilename({
companyName: settings.company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote: !!invoice.credited_invoice_id,
})
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId, {
@@ -393,6 +401,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
if (statusFlipped) {
try {
await recordManualInvoiceDelivery({
supabase,
companyId,
userId: user.id,
invoiceId: id,
})
} catch (err) {
log.error('failed to record manual invoice delivery', err as Error)
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket kunde inte sparas i fakturans historik.',
})
}
}
if (!isCreditNote) {
await eventBus.emit({
type: 'invoice.sent',
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
makeInvoice,
} from '@/tests/helpers'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const renderToBufferMock = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
SHOW_SWISH_ON_INVOICE: false,
}))
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 invoice = makeInvoice({
id: 'invoice-1',
invoice_number: '2621',
invoice_date: '2026-07-21',
customer,
items: [],
})
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await GET(
createMockRequest('/api/invoices/missing/pdf'),
createMockRouteParams({ id: 'missing' }),
)
expect(response.status).toBe(404)
})
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(200)
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
})
})
+12 -5
View File
@@ -3,6 +3,8 @@ import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
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'
@@ -80,16 +82,21 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
// Return PDF as response
const isCreditNote = !!invoice.credited_invoice_id
const filenameNumber = invoice.invoice_number ?? `utkast-${String(invoice.id).slice(0, 8)}`
const filename = isCreditNote
? `kreditfaktura-${filenameNumber}.pdf`
: `faktura-${filenameNumber}.pdf`
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: (invoice.customer as Customer).name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
return new NextResponse(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': pdfBuffer.length.toString(),
},
})
@@ -54,6 +54,47 @@ vi.mock('@/lib/email/service', () => ({
}),
}))
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
const mockLinkToJournalEntry = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/core/documents/document-service', () => ({
linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
@@ -313,6 +354,9 @@ describe('POST /api/invoices/[id]/send', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.messageId).toBe('msg-1')
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kund@test.se',
@@ -385,7 +429,9 @@ describe('POST /api/invoices/[id]/send', () => {
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({ filename: 'kreditfaktura-KR-F-2024001.pdf' }),
expect.objectContaining({
filename: 'Test Firma x Test AB Kreditfaktura nr KR-F-2024001 20240615.pdf',
}),
],
}),
)
@@ -612,6 +658,36 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { details?: { retryable?: boolean } }).details?.retryable).toBe(true)
})
it('does not call the provider when delivery history cannot be saved', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendTrackedInvoiceEmail.mockRejectedValueOnce(new Error('snapshot insert failed'))
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: unknown }>(response)
expect(status).toBe(500)
expect((body.error as { code: string }).code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('does not allocate an invoice number when delivery reservation fails', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockReserveInvoiceDelivery.mockRejectedValueOnce(new Error('reservation failed'))
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(500)
expect(body.error.code).toBe('INVOICE_SEND_SNAPSHOT_FAILED')
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(mockSendTrackedInvoiceEmail).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 400 on malformed lines before any email is sent', async () => {
enqueue({ data: invoice, error: null }) // ownership fetch precedes validation
const request = createMockRequest('/api/invoices/inv-1/send', {
+91 -41
View File
@@ -13,13 +13,19 @@ import {
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
issueCreditNote,
type CreditNoteOriginalInvoice,
} from '@/lib/invoices/issue-credit-note'
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -201,6 +207,22 @@ export const POST = withRouteContext(
}
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId: companyId!,
userId: user.id,
invoiceId: id,
})
} catch (err) {
opLog.error('failed to reserve invoice delivery before number assignment', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
// Allocate the F-series number. Idempotent: retries reuse the same number.
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
@@ -253,17 +275,15 @@ export const POST = withRouteContext(
company: company as CompanySettings,
}
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'proforma') {
filename = `proformafaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'delivery_note') {
filename = `foljesedel-${invoice.invoice_number}.pdf`
} else {
filename = `faktura-${invoice.invoice_number}.pdf`
}
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
const ccAddress = company.email || user.email
const partialFailures: Array<{ step: string; reason: string }> = []
@@ -344,24 +364,45 @@ export const POST = withRouteContext(
}
}
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
attachments: [
{
filename,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
})
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId: companyId!,
userId: user.id,
invoiceId: id,
deliveryId,
to: customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name,
filename,
pdfBuffer,
})
} catch (err) {
opLog.error('failed to persist invoice delivery snapshot before send', err as Error)
return errorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', opLog, {
requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
if (!result.success) {
if (result.trackingWarning) {
opLog.warn('invoice send: failed delivery snapshot not reconciled', {
invoiceId: id,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
opLog.error('email provider failed to send invoice', new Error(result.error || 'Unknown'))
return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, {
requestId,
@@ -369,6 +410,17 @@ export const POST = withRouteContext(
})
}
if (result.trackingWarning) {
opLog.warn('invoice send: delivery snapshot not finalised', {
invoiceId: id,
deliveryId: result.deliveryId,
})
partialFailures.push({
step: 'delivery_history',
reason: 'Utskicket sparades men kunde inte färdigmarkeras i historiken.',
})
}
// From here on the invoice has reached the customer. Failures in the
// follow-up steps degrade the response to PARTIAL: the user gets a
// success toast with a sub-warning, and the audit trail records exactly
@@ -489,22 +541,19 @@ export const POST = withRouteContext(
}
}
if (statusFlipped && isRealInvoice) {
if (statusFlipped && isRealInvoice && createdJournalEntryId) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId!, {
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
}, {
upload_source: 'system',
journal_entry_id: createdJournalEntryId,
})
await linkToJournalEntry(
supabase,
companyId!,
result.documentId,
createdJournalEntryId,
)
} catch (err) {
opLog.error('failed to store invoice PDF as underlag', err as Error)
opLog.error('failed to link archived invoice PDF to journal entry', err as Error)
partialFailures.push({
step: 'pdf_archive',
reason: 'Fakturans PDF kunde inte arkiveras.',
step: 'pdf_link',
reason: 'Fakturans arkiverade PDF kunde inte kopplas till verifikationen.',
})
}
}
@@ -530,6 +579,7 @@ export const POST = withRouteContext(
success: true,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId,
deliveryId: result.deliveryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
makeCompanySettings,
makeCustomer,
} from '@/tests/helpers'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
const renderToBufferMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => renderToBufferMock(...args),
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
}))
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
prepareInvoicePdfRender: vi.fn(async (company: unknown) => ({ branding: {}, company })),
buildSwishQrDataUrl: vi.fn().mockResolvedValue(null),
buildPaymentLinkQrDataUrl: vi.fn().mockResolvedValue(null),
}))
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 validBody = {
customer_id: customer.id,
invoice_number: '2621',
invoice_date: '2026-07-21',
due_date: '2026-08-20',
currency: 'SEK',
items: [{
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 14000,
vat_rate: 25,
}],
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
renderToBufferMock.mockResolvedValue(Buffer.from('pdf-bytes'))
})
it('returns 401 when the caller is not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(401)
})
it('returns 400 when invoice rows are missing', async () => {
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...validBody, items: [] },
}),
createMockRouteParams({}),
)
expect(response.status).toBe(400)
})
it('returns 404 when the customer does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(404)
})
it('returns a descriptive UTF-8 filename for the PDF preview', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: company, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/pdf')
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
})
})
+11 -1
View File
@@ -4,6 +4,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
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'
/**
@@ -196,11 +198,19 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
paymentLinkQrDataUrl,
})
)
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: customer.name,
invoiceNumber: previewInvoice.invoice_number,
invoiceId: previewInvoice.id,
invoiceDate: previewInvoice.invoice_date,
documentType: previewInvoice.document_type,
})
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
'Content-Disposition': contentDisposition('inline', filename),
},
})
} catch (error) {
@@ -41,6 +41,11 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
}),
}))
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import {
createInvoiceJournalEntry as mockedCreateEntry,
@@ -127,6 +132,7 @@ beforeEach(() => {
scopes: ['invoices:write'],
mode: 'live',
})
mockRecordManualInvoiceDelivery.mockResolvedValue({ id: 'delivery-1' })
})
describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
@@ -156,6 +162,12 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-sent', () => {
expect(body.data.invoice_number).toBe('2026-0042')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: expect.anything(),
companyId: COMPANY_ID,
userId: USER_ID,
invoiceId: INVOICE_ID,
})
})
it('returns 409 INVOICE_UPDATE_NOT_DRAFT when the invoice is already sent', async () => {
@@ -41,6 +41,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
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 { eventBus } from '@/lib/events'
import type { EntityType, Invoice } from '@/types'
@@ -350,7 +351,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Step 4: emit invoice.sent. Best-effort; escalate to error if it
// Step 4: preserve the manual delivery transition in immutable history.
try {
await recordManualInvoiceDelivery({
supabase: ctx.supabase,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
})
} catch (err) {
ctx.log.error('mark-sent: delivery history insert failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'DELIVERY_HISTORY_NOT_RECORDED',
message: 'Invoice was marked sent, but the manual delivery transition could not be added to its history.',
})
}
// Step 5: emit invoice.sent. Best-effort; escalate to error if it
// fails (downstream webhook delivery and audit trails depend on this).
try {
await eventBus.emit({
@@ -44,6 +44,7 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { contentDispositionFilename } from '@/lib/api/content-disposition'
import { GET as pdf } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
@@ -125,7 +126,7 @@ beforeEach(() => {
})
describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
it('returns a PDF for a sent invoice with the faktura-<number> filename', async () => {
it('returns a PDF for a sent invoice with a descriptive filename', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -141,11 +142,12 @@ 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('Content-Disposition')).toBe('attachment; filename="faktura-2026-0042.pdf"')
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_/)
})
it('uses utkast-<id-slice>.pdf filename for drafts', async () => {
it('uses an identifiable descriptive filename for drafts', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -163,15 +165,11 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
)
expect(res.status).toBe(200)
// Same composition as the dashboard's internal pdf route: the
// "faktura-" prefix is preserved, the number slot is the "utkast-<slice>"
// placeholder.
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="faktura-utkast-bbbbbbbb.pdf"',
)
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Faktura utkast-bbbbbbbb 20260512.pdf')
})
it('uses kreditfaktura-<number>.pdf for credit notes and embeds original number', async () => {
it('identifies credit notes in the filename and embeds the original number', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -196,9 +194,8 @@ describe('GET /api/v1/companies/:companyId/invoices/:id/pdf', () => {
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Disposition')).toBe(
'attachment; filename="kreditfaktura-2026-0099.pdf"',
)
expect(contentDispositionFilename(res.headers.get('Content-Disposition')))
.toBe('Test AB x Acme AB Kreditfaktura nr 2026-0099 20260512.pdf')
// The template received the original number: verify via the InvoicePDF mock call.
const call = (mockRender.mock.calls[0]?.[0] as unknown) as { props?: unknown } | undefined
expect(call).toBeDefined()
@@ -10,7 +10,7 @@
* PDF is still rendered: useful for "preview before send" workflows.
* - Sent / paid / overdue / cancelled / credit notes: full PDF with the
* persisted invoice number.
* - Credit notes: filename uses `kreditfaktura-` prefix and the original
* - Credit notes: the filename identifies the document as a kreditfaktura and the original
* invoice's löpnummer is embedded (ML 17 kap 22-23§ back-reference).
* - Delivery notes: PDF is permitted (read-only, no compliance side effect).
*
@@ -21,6 +21,8 @@ import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
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'
@@ -44,7 +46,7 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/invoices/:id/pdf',
summary: 'Download the rendered invoice PDF.',
description:
'Returns the invoice as application/pdf. The filename in Content-Disposition reflects the document type: faktura-<number>.pdf for sent invoices, kreditfaktura-<number>.pdf for credit notes, utkast-<id-slice>.pdf for drafts. This endpoint is byte-equivalent to the dashboard download.',
'Returns the invoice as application/pdf. The descriptive filename contains company, customer, document type, invoice number or draft identifier, and invoice date. This endpoint is byte-equivalent to the dashboard download.',
useWhen:
'You need to fetch an invoice PDF for archival, forwarding to a customer outside the Accounted send flow, or attaching to an external workflow.',
doNotUseFor:
@@ -175,21 +177,22 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
}
const isCreditNote = !!typed.credited_invoice_id
const filenameNumber = typed.invoice_number ?? `utkast-${invoiceId.slice(0, 8)}`
const filename = isCreditNote
? `kreditfaktura-${filenameNumber}.pdf`
: typed.document_type === 'proforma'
? `proformafaktura-${filenameNumber}.pdf`
: typed.document_type === 'delivery_note'
? `följesedel-${filenameNumber}.pdf`
: `faktura-${filenameNumber}.pdf`
const filename = invoicePdfFilename({
companyName: (company as CompanySettings).company_name,
customerName: typed.customer?.name,
invoiceNumber: typed.invoice_number,
invoiceId,
invoiceDate: typed.invoice_date,
documentType: typed.document_type,
isCreditNote,
})
const uint8Array = new Uint8Array(pdfBuffer)
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': String(pdfBuffer.length),
'X-Request-Id': ctx.requestId,
},
@@ -43,6 +43,7 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn().mockResolvedValue({}),
linkToJournalEntry: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@react-pdf/renderer', () => ({
@@ -63,6 +64,42 @@ vi.mock('@/lib/email/service', async (importOriginal) => {
}
})
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>...</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('plain text'),
@@ -216,6 +253,18 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
expect(body.data.sent_to).toBe('billing@acme.test')
expect(body.data.journal_entry_id).toBe('jjjjjjjj-jjjj-4jjj-8jjj-jjjjjjjjjjjj')
expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: COMPANY_ID, invoiceId: INVOICE_ID }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
expect.objectContaining({
filename: 'Test AB x Acme AB Faktura nr 2026-0042 20260512.pdf',
}),
],
}),
)
})
it('returns 503 when email service is not configured', async () => {
@@ -56,8 +56,14 @@ import {
generateInvoiceEmailText,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { eventBus } from '@/lib/events'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
@@ -326,6 +332,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
)
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase: ctx.supabase,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
})
} catch (err) {
ctx.log.error('invoices.send: delivery reservation failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
// Step 6: allocate F-series number atomically.
try {
await ensureInvoiceNumber(ctx.supabase, ctx.companyId!, typed as Invoice)
@@ -423,32 +448,59 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Step 8: send the email. Delivery notes AND credit notes were rejected
// earlier so docType is 'invoice' or 'proforma' here.
const docType = typed.document_type ?? 'invoice'
const filename =
docType === 'proforma'
? `proformafaktura-${finalInvoiceNumber}.pdf`
: `faktura-${finalInvoiceNumber}.pdf`
const filename = invoicePdfFilename({
companyName: settings.company_name,
customerName: customer.name,
invoiceNumber: finalInvoiceNumber,
invoiceId: typed.id,
invoiceDate: typed.invoice_date,
documentType: typed.document_type,
})
const ccAddress = settings.email ?? null
const emailData = { invoice: renderableInvoice, customer, company: settings }
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress ?? undefined,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: settings.email ?? undefined,
fromName: settings.company_name ?? undefined,
attachments: [
{
filename,
content: pdfBuffer,
contentType: 'application/pdf',
},
],
})
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase: ctx.supabase,
emailService,
companyId: ctx.companyId!,
userId: ctx.userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress ?? undefined,
subject,
html,
text,
replyTo: settings.email ?? undefined,
fromName: settings.company_name ?? undefined,
filename,
pdfBuffer,
})
} catch (err) {
ctx.log.error('invoices.send: delivery snapshot failed before email', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INVOICE_SEND_SNAPSHOT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { retryable: err instanceof InvoiceDeliverySnapshotError },
})
}
if (!result.success) {
if (result.trackingWarning) {
ctx.log.warn('invoices.send: failed delivery snapshot not reconciled', {
invoiceId,
companyId: ctx.companyId,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
ctx.log.error('invoices.send: email provider failed', new Error(result.error ?? 'unknown'), {
invoiceId,
companyId: ctx.companyId,
@@ -462,6 +514,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Email has been delivered. Subsequent failures surface as warnings.
const warnings: { code: string; message: string }[] = []
if (result.trackingWarning) {
ctx.log.warn('invoices.send: delivery snapshot not finalized', {
invoiceId,
companyId: ctx.companyId,
deliveryId: result.deliveryId,
})
warnings.push({
code: 'DELIVERY_HISTORY_FINALIZE_FAILED',
message: 'The delivery snapshot exists but could not be finalized. Reconcile the pending delivery record.',
})
}
if (paymentLinkFailure) {
warnings.push({ code: 'PAYMENT_LINK_FAILED', message: paymentLinkFailure })
}
@@ -547,32 +611,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
}
// Step 9c: archive the PDF as underlag.
if (isRealInvoice) {
// Step 9c: link the already archived exact delivery PDF to the entry.
if (isRealInvoice && journalEntryId) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(
await linkToJournalEntry(
ctx.supabase,
ctx.userId,
ctx.companyId!,
{
name: filename,
buffer: pdfArrayBuffer,
type: 'application/pdf',
},
{
upload_source: 'system',
journal_entry_id: journalEntryId ?? undefined,
},
result.documentId,
journalEntryId,
)
} catch (err) {
ctx.log.error('invoices.send: PDF archival failed', err as Error, {
ctx.log.error('invoices.send: archived PDF journal link failed', err as Error, {
invoiceId,
companyId: ctx.companyId,
})
warnings.push({
code: 'PDF_ARCHIVE_FAILED',
message: 'Invoice was sent but the PDF could not be archived as underlag. Manual upload required for BFL 7 kap retention.',
code: 'PDF_JOURNAL_LINK_FAILED',
message: 'The exact sent PDF was archived but could not be linked to the journal entry.',
})
}
}
+14 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
@@ -41,15 +41,19 @@ interface AccountComboboxProps {
// to the next konteringsrad's account on Enter: see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
disabled?: boolean
// Optional always-visible label for compact editors where the selected
// account name must remain readable after the dropdown closes.
selectedName?: string
}
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false, selectedName }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const selectedNameId = useId()
// Whether the user has typed or arrow-navigated since the field was focused.
// Enter only selects the highlighted item after an actual interaction: a
// bare Enter on a freshly-focused field must not grab the first account in
@@ -236,6 +240,8 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}, 150)
}
const showSelectedName = Boolean(selectedName && value && search === value)
return (
<div ref={containerRef} className="relative">
<Input
@@ -249,8 +255,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
disabled={disabled}
aria-describedby={showSelectedName ? selectedNameId : undefined}
/>
{showSelectedName ? (
<p id={selectedNameId} className="mt-1 break-words px-1 text-sm leading-snug text-foreground">
{selectedName}
</p>
) : null}
{/* Dropdown */}
{isOpen && !disabled && flatList.length > 0 && (
+38 -3
View File
@@ -30,6 +30,41 @@ let uploadCounter = 0
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp'
const FILE_NAME_TAIL_LENGTH = 16
function TruncatedFileName({ fileName }: { fileName: string }) {
const characters = Array.from(fileName)
if (characters.length <= FILE_NAME_TAIL_LENGTH * 2) {
return (
<span className="min-w-0 flex-1 truncate" title={fileName}>
{fileName}
</span>
)
}
const splitAt = characters.length - FILE_NAME_TAIL_LENGTH
const start = characters.slice(0, splitAt).join('')
const end = characters.slice(splitAt).join('')
return (
<span
className="flex min-w-0 flex-1"
title={fileName}
>
<span className="sr-only">{fileName}</span>
<span aria-hidden="true" className="min-w-0 flex-1 truncate">
{start}
</span>
<span
aria-hidden="true"
className="max-w-1/2 shrink overflow-hidden whitespace-nowrap text-right"
>
{end}
</span>
</span>
)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -208,7 +243,7 @@ export default function DocumentUploadZone({
const isUploading = files.some((f) => f.status === 'uploading')
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{/* Drop zone */}
<div
className={`
@@ -253,14 +288,14 @@ export default function DocumentUploadZone({
{files.map((file, index) => (
<div
key={file.uploadKey}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
className="flex min-w-0 items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
{isImageType(file.file.type) ? (
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate flex-1">{file.fileName}</span>
<TruncatedFileName fileName={file.fileName} />
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(file.fileSize)}
</span>
+1 -1
View File
@@ -1233,7 +1233,7 @@ export default function JournalEntryList() {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium sensitive-field">
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium rr-mask">
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
</span>
<div className="flex shrink-0 items-center gap-1">
@@ -0,0 +1,159 @@
'use client'
import { useFormatter, useTranslations } from 'next-intl'
import { ExternalLink, Mail, Send } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import type { InvoiceDelivery } from '@/types'
export type InvoiceDeliveryView = Pick<
InvoiceDelivery,
| 'id'
| 'channel'
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'sent_at'
| 'failed_at'
| 'created_at'
> & {
status: 'pending' | 'sent' | 'failed' | 'marked_sent'
}
interface InvoiceDeliveryHistoryProps {
deliveries: InvoiceDeliveryView[]
showLegacyEmptyState: boolean
}
const statusVariant = {
pending: 'secondary',
sent: 'success',
failed: 'destructive',
marked_sent: 'outline',
} as const
export function InvoiceDeliveryHistory({
deliveries,
showLegacyEmptyState,
}: InvoiceDeliveryHistoryProps) {
const t = useTranslations('invoice_detail')
const format = useFormatter()
if (deliveries.length === 0 && !showLegacyEmptyState) return null
const formatTimestamp = (value: string) =>
format.dateTime(new Date(value), {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
return (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t('delivery_history_title')}
</CardTitle>
<CardDescription>{t('delivery_history_description')}</CardDescription>
</CardHeader>
<CardContent>
{deliveries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
</div>
) : (
<div className="space-y-3">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
return (
<details key={delivery.id} className="group rounded-lg border bg-card">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatTimestamp(occurredAt)}
</span>
</span>
<Badge variant={statusVariant[delivery.status]}>
{t(`delivery_status_${delivery.status}`)}
</Badge>
</summary>
<div className="px-4 pb-4">
<Separator className="mb-4" />
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<div className="space-y-4 text-sm">
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
</dl>
{delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full">
<a
href={`/api/documents/${delivery.document_attachment_id}/inline`}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
<span className="truncate">
{t('delivery_open_pdf', { filename: t('delivery_pdf_fallback') })}
</span>
</a>
</Button>
)}
{(delivery.provider || delivery.error_code) && (
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
{delivery.provider && (
<>
<dt>{t('delivery_provider_label')}</dt>
<dd>{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt>{t('delivery_error_label')}</dt>
<dd>{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
)}
</div>
</details>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
+14 -1
View File
@@ -27,6 +27,7 @@ import { useCompany } from '@/contexts/CompanyContext'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
@@ -77,7 +78,13 @@ export default function PaymentBookingDialog({
}
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [lines, setLines] = useState<FormLine[]>([])
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)
return names
}, [accounts, catalog])
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [isSubmitting, setIsSubmitting] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
@@ -108,7 +115,10 @@ export default function PaymentBookingDialog({
async function init() {
try {
// Fetch accounts
const accountsRes = await fetch('/api/bookkeeping/accounts')
const [accountsRes, fetchedCatalog] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
const fetchedAccounts: BASAccount[] = accountsData.data || []
@@ -126,6 +136,7 @@ export default function PaymentBookingDialog({
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -424,6 +435,7 @@ export default function PaymentBookingDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Button
@@ -490,6 +502,7 @@ export default function PaymentBookingDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Input
+16 -1
View File
@@ -30,6 +30,7 @@ import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
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'
interface InvoiceWithRelations extends Invoice {
customer: Customer
@@ -68,8 +69,14 @@ export default function SendInvoiceDialog({
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
const [deferBooking, setDeferBooking] = useState(false)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [editLines, setEditLines] = useState<FormLine[]>([])
const [hasEdited, setHasEdited] = useState(false)
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)
return names
}, [accounts, catalog])
// The accrual book-at-issue path (both email send and manual mark-sent)
// lets the user adjust the proposed lines before booking (same editor as
@@ -133,16 +140,22 @@ export default function SendInvoiceDialog({
// Line editing needs the chart of accounts; only the accrual
// book-at-issue path renders the editor, so skip the fetch elsewhere.
let fetchedAccounts: BASAccount[] = []
let fetchedCatalog: CatalogAccount[] = []
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
const accountsRes = await fetch('/api/bookkeeping/accounts')
const [accountsRes, catalogResult] = await Promise.all([
fetch('/api/bookkeeping/accounts'),
loadBasCatalog(),
])
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
const accountsData = await accountsRes.json()
fetchedAccounts = accountsData.data || []
fetchedCatalog = catalogResult
}
if (cancelled) return
setAccounts(fetchedAccounts)
setCatalog(fetchedCatalog)
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
setPeriodName(periodResult.data?.name || '')
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
@@ -427,6 +440,7 @@ export default function SendInvoiceDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Button
@@ -504,6 +518,7 @@ export default function SendInvoiceDialog({
value={line.account_number}
accounts={accounts}
onChange={(val) => updateLine(index, 'account_number', val)}
selectedName={accountNameByNumber.get(line.account_number)}
/>
</div>
<Input
@@ -45,14 +45,12 @@ import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
import type { TransactionWithInvoice, HistoryFilter, SourceFilter } from './transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
StoredSkattekontoTransaction,
} from '@/types/skatteverket'
type SourceFilter = 'all' | 'bank' | 'skatteverket'
type HistoryRow =
| { source: 'bank'; date: string; data: TransactionWithInvoice }
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
@@ -61,6 +59,8 @@ interface TransactionHistoryListProps {
transactions: TransactionWithInvoice[]
skvRows?: SkattekontoTransactionWithSuggestion[]
searchTerm?: string
sourceFilter: SourceFilter
onSourceFilterChange: (sourceFilter: SourceFilter) => void
/** Underlag status per journal_entry_id (computeJeUnderlagStatus): drives
* the per-row "Underlag"/"Underlag saknas" badges on booked rows. */
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
@@ -80,6 +80,8 @@ export default function TransactionHistoryList({
transactions,
skvRows = [],
searchTerm = '',
sourceFilter,
onSourceFilterChange,
jeUnderlagStatus,
onOpenMatchDialog,
onOpenCategoryDialog,
@@ -93,7 +95,6 @@ export default function TransactionHistoryList({
}: TransactionHistoryListProps) {
const t = useTranslations('tx_history')
const [filter, setFilter] = useState<HistoryFilter>('all')
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
// The bank/private filter doesn't apply to SKV rows: they have no
// is_business flag. So when the filter is 'business' or 'private' we
@@ -128,7 +129,7 @@ export default function TransactionHistoryList({
return a.source === 'bank' ? -1 : 1
})
const showSourceFilter = skvRows.length > 0 && transactions.length > 0
const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0)
const filtered = merged
const showHeader = showSourceFilter
@@ -163,7 +164,7 @@ export default function TransactionHistoryList({
<DropdownMenuContent align="start" className="min-w-[12rem]">
<DropdownMenuRadioGroup
value={sourceFilter}
onValueChange={(v) => setSourceFilter(v as SourceFilter)}
onValueChange={(v) => onSourceFilterChange(v as SourceFilter)}
>
<DropdownMenuRadioItem value="all">{t('source_all')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bank">{t('source_bank')}</DropdownMenuRadioItem>
@@ -9,6 +9,7 @@ export interface TransactionWithInvoice extends Transaction {
// Page view modes
export type ViewMode = 'inbox' | 'history'
export type HistoryFilter = 'all' | 'business' | 'private'
export type SourceFilter = 'all' | 'bank' | 'skatteverket'
// Handler types
// Returns the journal_entry_id on success, null on failure
@@ -72,14 +72,15 @@ export class ResendEmailService implements EmailService {
if (response.error) {
log.error('Resend error:', response.error)
return { success: false, error: response.error.message }
return { success: false, provider: 'resend', error: response.error.message }
}
return { success: true, messageId: response.data?.id }
return { success: true, provider: 'resend', messageId: response.data?.id }
} catch (error) {
log.error('Failed to send email:', error)
return {
success: false,
provider: 'resend',
error: error instanceof Error ? error.message : 'Unknown error',
}
}
+27 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { contentDisposition } from '../content-disposition'
import { contentDisposition, contentDispositionFilename } from '../content-disposition'
describe('contentDisposition', () => {
it('passes a plain ASCII filename through unchanged in both forms', () => {
@@ -45,12 +45,13 @@ describe('contentDisposition', () => {
).not.toThrow()
})
it('neutralizes quote and CRLF header injection', () => {
const header = contentDisposition('attachment', 'evil"\r\nSet-Cookie: x=y.pdf')
it('neutralizes header delimiters and CRLF injection', () => {
const header = contentDisposition('attachment', 'evil";\\\r\nSet-Cookie: x=y.pdf')
expect(header).not.toContain('\r')
expect(header).not.toContain('\n')
expect(header).toContain('filename="evil___Set-Cookie: x=y.pdf"')
expect(header).toContain('filename="evil_____Set-Cookie: x=y.pdf"')
// The extended form percent-encodes them instead of emitting them raw.
expect(header).toContain('%22%3B%5C')
expect(header).toContain('%0D%0A')
})
@@ -89,3 +90,25 @@ describe('contentDisposition', () => {
expect(() => new Headers({ 'Content-Disposition': header })).not.toThrow()
})
})
describe('contentDispositionFilename', () => {
it('prefers and decodes the UTF-8 filename', () => {
const header = contentDisposition(
'attachment',
'Företag x Kund AB Faktura nr 2621 20260721.pdf',
)
expect(contentDispositionFilename(header))
.toBe('Företag x Kund AB Faktura nr 2621 20260721.pdf')
})
it('falls back to the quoted ASCII filename', () => {
expect(contentDispositionFilename('attachment; filename="faktura-2621.pdf"'))
.toBe('faktura-2621.pdf')
})
it('returns null for a missing or malformed filename', () => {
expect(contentDispositionFilename(null)).toBeNull()
expect(contentDispositionFilename('attachment')).toBeNull()
})
})
+19 -3
View File
@@ -28,9 +28,9 @@ export function contentDisposition(
const normalized = filename.toWellFormed().normalize('NFC')
// ASCII fallback for the quoted-string form: anything outside printable
// ASCII, plus the quoted-string specials " and \, becomes _. This also
// neutralizes CR/LF header injection.
const fallback = normalized.replace(/[^\x20-\x7e]|["\\]/g, '_')
// ASCII, plus structurally significant header characters, becomes _. This
// also neutralizes CR/LF header injection.
const fallback = normalized.replace(/[^\x20-\x7e]|["\\;]/g, '_')
// RFC 5987 value-chars: encodeURIComponent covers everything except
// ! ' ( ) * which it leaves bare but RFC 5987 forbids unencoded.
@@ -41,3 +41,19 @@ export function contentDisposition(
return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`
}
/** Read the preferred UTF-8 filename from a Content-Disposition header. */
export function contentDispositionFilename(header: string | null): string | null {
if (!header) return null
const extended = header.match(/(?:^|;)\s*filename\*=UTF-8''([^;]*)/i)
if (extended?.[1]) {
try {
return decodeURIComponent(extended[1])
} catch {
// Fall through to the ASCII quoted-string form.
}
}
return header.match(/(?:^|;)\s*filename="([^"]*)"/i)?.[1] ?? null
}
@@ -78,6 +78,98 @@ describe('proposeSendLines', () => {
})
})
it('does not create zero-value revenue rows for informational invoice items', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem(),
makeItem({
id: 'text-1',
line_type: 'text',
description: 'Information shown on the invoice',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
expect(lines.some((line) =>
(parseFloat(line.debit_amount) || 0) === 0
&& (parseFloat(line.credit_amount) || 0) === 0
)).toBe(false)
})
it('ignores informational rows when selecting the legacy invoice VAT treatment', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem({ vat_rate: undefined }),
makeItem({
id: 'text-1',
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines.map((line) => line.account_number)).toEqual(['1510', '3001', '2611'])
})
it('returns no booking proposal for an invoice containing only informational rows', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
total: 0,
subtotal: 0,
vat_amount: 0,
items: [
makeItem({
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines).toEqual([])
})
it('rejects a non-zero invoice containing only informational rows', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
items: [
makeItem({
line_type: 'text',
quantity: 0,
unit_price: 0,
line_total: 0,
vat_rate: 0,
vat_amount: 0,
}),
],
}),
entityType: 'enskild_firma',
})
expect(lines).toEqual([])
})
it('credit note uses positive amounts on the reversed sides', () => {
const lines = proposeSendLines({
invoice: makeInvoiceInput({
+30 -9
View File
@@ -121,14 +121,22 @@ function buildSendLines(
// Build credit lines per VAT rate group
const creditLines: FormLine[] = []
const accountingItems = (invoice.items ?? []).filter((item) => item.line_type !== 'text')
if (invoice.items && invoice.items.length > 0) {
const hasPerLineVat = invoice.items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
// Existing informational rows are never a valid source for an invoice-level
// amount. Returning no proposal keeps an inconsistent text-only invoice from
// producing a debit-only entry; the user must correct its economic rows.
if (accountingItems.length === 0 && (invoice.items?.length ?? 0) > 0) {
return []
}
if (accountingItems.length > 0) {
const hasPerLineVat = accountingItems.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy: single rate from invoice level
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotal = invoice.items.reduce((sum, item) => sum + item.line_total, 0)
const subtotal = accountingItems.reduce((sum, item) => sum + item.line_total, 0)
creditLines.push({
account_number: revenueAccount,
debit_amount: '',
@@ -136,7 +144,7 @@ function buildSendLines(
line_description: desc,
})
const totalVat = invoice.items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
const totalVat = accountingItems.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
creditLines.push({
@@ -149,7 +157,7 @@ function buildSendLines(
} else {
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of invoice.items) {
for (const item of accountingItems) {
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
@@ -182,7 +190,7 @@ function buildSendLines(
}
}
}
} else {
} else if (!invoice.items || invoice.items.length === 0) {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const subtotalSek = resolveSekAmount(invoice.subtotal, invoice.subtotal_sek, invoice.currency, invoice.exchange_rate)
@@ -207,7 +215,7 @@ function buildSendLines(
const deductionLines: FormLine[] = []
let deductionTotal = 0
for (const item of invoice.items ?? []) {
for (const item of accountingItems) {
if (!item.deduction_type) continue
const deduction = computeDeduction({
unit_price: item.unit_price,
@@ -225,13 +233,26 @@ function buildSendLines(
})
}
// Text-only and other informational invoice rows carry zero totals. They
// must not become misleading 30xx rows in the booking preview.
const nonZeroCreditLines = creditLines.filter(
(line) => roundOre(parseFloat(line.credit_amount) || 0) !== 0,
)
// Debit: 1510 customer portion plus 1513 Skatteverket portion.
const totalCredits = creditLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const totalCredits = nonZeroCreditLines.reduce(
(sum, line) => sum + (parseFloat(line.credit_amount) || 0),
0,
)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
const customerReceivable = roundOre(debitAmount - deductionTotal)
if (customerReceivable === 0 && deductionLines.length === 0 && nonZeroCreditLines.length === 0) {
return []
}
lines.push({
account_number: '1510',
debit_amount: toFormAmount(customerReceivable),
@@ -240,7 +261,7 @@ function buildSendLines(
})
lines.push(...deductionLines)
lines.push(...creditLines)
lines.push(...nonZeroCreditLines)
return lines
}
+1
View File
@@ -23,6 +23,7 @@ export interface SendEmailOptions {
export interface SendEmailResult {
success: boolean
provider?: string
messageId?: string
error?: string
}
+5
View File
@@ -900,6 +900,11 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'E-postleverantören kunde inte skicka meddelandet.',
message_en: 'The email provider could not deliver the message.',
},
INVOICE_SEND_SNAPSHOT_FAILED: {
httpStatus: 500,
message_sv: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.',
message_en: 'The delivery snapshot could not be saved. No email was sent.',
},
INVOICE_SEND_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv:
@@ -0,0 +1,389 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from '@/tests/pg/setup'
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
async function insertInvoice(userId: string, companyId: string): Promise<string> {
const customerId = randomUUID()
const invoiceId = randomUUID()
await getPool().query(
`INSERT INTO public.customers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Delivery History Customer')`,
[customerId, userId, companyId],
)
await getPool().query(
`INSERT INTO public.invoices
(id, user_id, company_id, customer_id, invoice_number,
invoice_date, due_date, currency, subtotal, vat_amount, total,
vat_treatment, vat_rate, moms_ruta, status)
VALUES ($1, $2, $3, $4, $5,
'2026-07-22', '2026-08-21', 'SEK', 1000, 250, 1250,
'standard_25', 25, '10', 'sent')`,
[invoiceId, userId, companyId, customerId, `F-${randomUUID().slice(0, 8)}`],
)
return invoiceId
}
async function insertDocument(userId: string, companyId: string): Promise<string> {
const documentId = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash)
VALUES ($1, $2, $3, $4, 'invoice.pdf', 1024, 'application/pdf', $5)`,
[
documentId,
userId,
companyId,
`documents/${userId}/${documentId}.pdf`,
'a'.repeat(64),
],
)
return documentId
}
async function insertManualDelivery(params: {
userId: string
companyId: string
invoiceId: string
}): Promise<string> {
const deliveryId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status, sent_at)
VALUES ($1, $2, $3, $4, 'manual', 'marked_sent', now())`,
[deliveryId, params.userId, params.companyId, params.invoiceId],
)
return deliveryId
}
async function insertPendingEmailDelivery(params: {
userId: string
companyId: string
invoiceId: string
documentId: string
retentionExpiresAt?: string
}): Promise<string> {
const deliveryId = randomUUID()
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,
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'],
'sender@example.com', 'Example AB', 'Faktura F-1001',
'Exact plain text', '<p>Exact HTML</p>', $5,
'invoice.pdf', 'application/pdf', $6, $7)`,
[
deliveryId,
params.userId,
params.companyId,
params.invoiceId,
params.documentId,
'a'.repeat(64),
params.retentionExpiresAt ?? null,
],
)
return deliveryId
}
describe('invoice_deliveries.pg: immutable delivery evidence', () => {
it('allows only a pending to terminal transition and then locks the row', async () => {
const { userId, companyId } = await seedCompany()
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', provider = 'resend',
provider_message_id = 'provider-1', sent_at = now()
WHERE id = $1`,
[deliveryId],
)
await expect(
getPool().query(
`UPDATE public.invoice_deliveries SET body_text = 'tampered' WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
await getPool().query(`DELETE FROM public.invoice_deliveries WHERE id = $1`, [deliveryId])
const retained = await getPool().query(
`SELECT id FROM public.invoice_deliveries WHERE id = $1`,
[deliveryId],
)
const deleteAudit = await getPool().query(
`SELECT old_state
FROM public.audit_log
WHERE table_name = 'invoice_deliveries'
AND record_id = $1
AND action = 'SECURITY_EVENT'
ORDER BY created_at DESC
LIMIT 1`,
[deliveryId],
)
expect(retained.rowCount).toBe(1)
expect(deleteAudit.rowCount).toBe(1)
expect(deleteAudit.rows[0].old_state).not.toHaveProperty('body_text')
expect(deleteAudit.rows[0].old_state).not.toHaveProperty('to_addresses')
})
it('blocks payload changes while finalizing a pending email', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
})
await expect(
getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'sent', sent_at = now(), subject = 'Changed subject'
WHERE id = $1`,
[deliveryId],
),
).rejects.toThrow(/invoice delivery payload is immutable/i)
})
it('rejects invoice and document references from another company', async () => {
const a = await seedCompany()
const b = await seedCompany()
const invoiceA = await insertInvoice(a.userId, a.companyId)
const documentB = await insertDocument(b.userId, b.companyId)
await expect(
insertManualDelivery({
userId: b.userId,
companyId: b.companyId,
invoiceId: invoiceA,
}),
).rejects.toThrow(/invoice delivery invoice\/company mismatch/i)
await expect(
insertPendingEmailDelivery({
userId: a.userId,
companyId: a.companyId,
invoiceId: invoiceA,
documentId: documentB,
}),
).rejects.toThrow(/invoice delivery document\/company mismatch/i)
})
it('prevents deletion of the exact PDF after a successful send', async () => {
const { userId, companyId } = await seedCompany()
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],
)
await expect(
getPool().query(`DELETE FROM public.document_attachments WHERE id = $1`, [documentId]),
).rejects.toThrow(/exact PDF sent with a customer invoice/i)
})
it('isolates delivery history by company through RLS', async () => {
const a = await seedCompany()
const b = await seedCompany()
const deliveryA = await insertManualDelivery({
userId: a.userId,
companyId: a.companyId,
invoiceId: await insertInvoice(a.userId, a.companyId),
})
await insertManualDelivery({
userId: b.userId,
companyId: b.companyId,
invoiceId: await insertInvoice(b.userId, b.companyId),
})
const visibleIds = await withUserContext(a.userId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT id FROM public.invoice_deliveries WHERE company_id = ANY($1::uuid[])`,
[[a.companyId, b.companyId]],
)
return result.rows.map((row) => row.id)
})
expect(visibleIds).toEqual([deliveryA])
})
it('denies inserts to a viewer', async () => {
const { userId, companyId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
const invoiceId = await insertInvoice(userId, companyId)
await expect(
withUserContext(viewerId, async (client) => {
await 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())`,
[viewerId, companyId, invoiceId],
)
}),
).rejects.toThrow(/row-level security|policy/i)
})
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)
const documentId = await insertDocument(userId, companyId)
const deliveryId = randomUUID()
await getPool().query(
`INSERT INTO public.invoice_deliveries
(id, user_id, company_id, invoice_id, channel, status)
VALUES ($1, $2, $3, $4, 'email', 'preparing')`,
[deliveryId, userId, companyId, invoiceId],
)
await expect(
getPool().query(
`INSERT INTO public.invoice_deliveries
(user_id, company_id, invoice_id, channel, status)
VALUES ($1, $2, $3, 'email', 'preparing')`,
[userId, companyId, invoiceId],
),
).rejects.toThrow(/duplicate key|unique constraint/i)
await getPool().query(
`UPDATE public.invoice_deliveries
SET status = 'pending',
to_addresses = ARRAY['customer@example.com'],
subject = 'Faktura F-1001',
body_text = 'Exact plain text',
body_html = '<p>Exact HTML</p>',
document_attachment_id = $2,
attachment_filename = 'invoice.pdf',
attachment_content_type = 'application/pdf',
attachment_sha256 = $3
WHERE id = $1`,
[deliveryId, documentId, 'a'.repeat(64)],
)
const result = await getPool().query(
`SELECT status, retention_expires_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(result.rows[0].status).toBe('pending')
expect(result.rows[0].retention_expires_at).toBeTruthy()
})
it('allows a failed attempt to release and delete its unsent PDF', async () => {
const { userId, companyId } = await seedCompany()
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 = 'failed', failed_at = now(),
error_code = 'provider_failed', document_attachment_id = NULL
WHERE id = $1`,
[deliveryId],
)
await getPool().query(
`DELETE FROM public.document_attachments WHERE id = $1`,
[documentId],
)
const document = await getPool().query(
`SELECT id FROM public.document_attachments WHERE id = $1`,
[documentId],
)
expect(document.rowCount).toBe(0)
})
it('redacts expired delivery PII and keeps metadata-only audit state', async () => {
const { userId, companyId } = await seedCompany()
const invoiceId = await insertInvoice(userId, companyId)
const documentId = await insertDocument(userId, companyId)
const deliveryId = await insertPendingEmailDelivery({
userId,
companyId,
invoiceId,
documentId,
retentionExpiresAt: '2000-01-01',
})
await getPool().query(
`UPDATE public.invoice_deliveries SET status = 'sent', sent_at = now() WHERE id = $1`,
[deliveryId],
)
await getPool().query(`SELECT public.redact_expired_invoice_delivery_pii()`)
const delivery = await getPool().query(
`SELECT to_addresses, body_text, subject, provider_message_id,
attachment_filename, attachment_sha256, pii_redacted_at
FROM public.invoice_deliveries
WHERE id = $1`,
[deliveryId],
)
expect(delivery.rows[0]).toMatchObject({
to_addresses: [],
body_text: null,
subject: null,
provider_message_id: null,
attachment_filename: null,
attachment_sha256: null,
})
expect(delivery.rows[0].pii_redacted_at).toBeTruthy()
const audit = await getPool().query(
`SELECT new_state
FROM public.audit_log
WHERE table_name = 'invoice_deliveries'
AND record_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[deliveryId],
)
expect(audit.rows[0].new_state).not.toHaveProperty('body_text')
expect(audit.rows[0].new_state).not.toHaveProperty('to_addresses')
})
it('uses restrictive parent foreign keys for immutable delivery evidence', async () => {
const constraints = await getPool().query<{ conname: string; confdeltype: string }>(
`SELECT conname, confdeltype
FROM pg_constraint
WHERE conrelid = 'public.invoice_deliveries'::regclass
AND conname IN (
'invoice_deliveries_company_id_fkey',
'invoice_deliveries_user_id_fkey'
)
ORDER BY conname`,
)
expect(constraints.rows).toEqual([
{ conname: 'invoice_deliveries_company_id_fkey', confdeltype: 'r' },
{ conname: 'invoice_deliveries_user_id_fkey', confdeltype: 'r' },
])
})
})
@@ -0,0 +1,251 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService } from '@/lib/email/service'
const mockUploadDocument = vi.fn()
const mockDeleteDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
}))
import {
InvoiceDeliverySnapshotError,
recordManualInvoiceDelivery,
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} 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
snapshotError?: { message: string } | null
terminalError?: { 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),
}
chain.eq.mockReturnValue(chain)
chain.select.mockReturnValue(chain)
return chain
})
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 }))
return {
supabase: { from } as unknown as SupabaseClient,
insertSpy,
updateSpy,
}
}
function makeInput(supabase: SupabaseClient, emailService: EmailService) {
return {
supabase,
emailService,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
deliveryId: 'delivery-1',
to: 'customer@example.com',
cc: ['accounting@example.com'],
replyTo: 'sender@example.com',
fromName: 'Example AB',
subject: 'Faktura F-1001',
html: '<p>Hej!</p>',
text: 'Hej!',
filename: 'faktura-f-1001.pdf',
pdfBuffer: Buffer.from('exact-pdf'),
}
}
function makeEmailService(sendEmail: ReturnType<typeof vi.fn>): EmailService {
return { isConfigured: () => true, sendEmail }
}
describe('invoice delivery tracking', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUploadDocument.mockResolvedValue({
id: 'document-1',
sha256_hash: 'sha256-exact-pdf',
})
mockDeleteDocument.mockResolvedValue({ ok: true })
})
it('persists the exact payload before sending and records provider success', async () => {
const { supabase, updateSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: true,
provider: 'resend',
messageId: 'provider-message-1',
})
const result = await sendTrackedInvoiceEmail(
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(sendEmail).toHaveBeenCalledWith(expect.objectContaining({
subject: 'Faktura F-1001',
text: 'Hej!',
html: '<p>Hej!</p>',
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(result).toMatchObject({
success: true,
deliveryId: 'delivery-1',
documentId: 'document-1',
})
})
it('does not call the provider when the immutable snapshot cannot be saved', async () => {
const { supabase } = makeSupabase({
snapshotData: null,
snapshotError: { message: 'update failed' },
})
const sendEmail = vi.fn()
await expect(
sendTrackedInvoiceEmail(makeInput(supabase, makeEmailService(sendEmail))),
).rejects.toBeInstanceOf(InvoiceDeliverySnapshotError)
expect(sendEmail).not.toHaveBeenCalled()
expect(mockDeleteDocument).toHaveBeenCalledWith(
supabase,
'company-1',
'document-1',
)
})
it('records a failed provider attempt without changing the saved payload', async () => {
const { supabase, updateSpy } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({
success: false,
provider: 'resend',
messageId: 'provider-returned-on-failure',
error: 'provider rejected the request',
})
const result = await sendTrackedInvoiceEmail(
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(mockDeleteDocument).toHaveBeenCalledWith(supabase, 'company-1', 'document-1')
expect(result.success).toBe(false)
})
it('surfaces a warning if a successful provider result cannot be finalized', async () => {
const { supabase } = makeSupabase({ terminalError: { message: 'update failed' } })
const sendEmail = vi.fn().mockResolvedValue({ success: true })
const result = await sendTrackedInvoiceEmail(
makeInput(supabase, makeEmailService(sendEmail)),
)
expect(result.trackingWarning).toBe('finalize_failed')
})
it('reuses an existing preparing reservation after a unique conflict', async () => {
const { supabase } = makeSupabase({
insertData: null,
insertError: { message: 'duplicate', code: '23505' },
existingData: { id: 'delivery-existing' },
})
await expect(reserveInvoiceDelivery({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
})).resolves.toBe('delivery-existing')
})
it('records manual delivery without inventing recipient or content details', async () => {
const manualDelivery = {
id: 'delivery-1',
channel: 'manual',
status: 'marked_sent',
}
const { supabase, insertSpy } = makeSupabase({ insertData: manualDelivery })
await recordManualInvoiceDelivery({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
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',
})
})
})
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { invoicePdfFilename } from '../pdf-filename'
describe('invoicePdfFilename', () => {
it('includes company, customer, document type, number, and invoice date', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})).toBe('Oppy x Kund AB Faktura nr 2621 20260721.pdf')
})
it('uses the correct label for credit notes and other document types', () => {
const base = {
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceNumber: '42',
invoiceDate: '2026-07-21',
}
expect(invoicePdfFilename({ ...base, isCreditNote: true }))
.toContain('Kreditfaktura nr 42')
expect(invoicePdfFilename({ ...base, documentType: 'proforma' }))
.toContain('Proformafaktura nr 42')
expect(invoicePdfFilename({ ...base, documentType: 'delivery_note' }))
.toContain('Följesedel nr 42')
})
it('keeps drafts identifiable without inventing an invoice number', () => {
expect(invoicePdfFilename({
companyName: 'Oppy',
customerName: 'Kund AB',
invoiceId: 'bbbbbbbb-1111-2222-3333-cccccccccccc',
invoiceDate: '2026-07-21',
})).toBe('Oppy x Kund AB Faktura utkast-bbbbbbbb 20260721.pdf')
})
it('removes characters that are unsafe in cross-platform filenames', () => {
expect(invoicePdfFilename({
companyName: 'Oppy / Sverige',
customerName: 'Kund: "Nord" * AB',
invoiceNumber: '../26/21',
invoiceDate: '2026-07-21',
})).toBe('Oppy Sverige x Kund Nord AB Faktura nr .. 26 21 20260721.pdf')
})
it('falls back when company and customer names are empty', () => {
expect(invoicePdfFilename({
companyName: ' ',
customerName: null,
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})).toBe('Företag x Kund Faktura nr 2621 20260721.pdf')
})
it('keeps multibyte filenames within common filesystem byte limits', () => {
const filename = invoicePdfFilename({
companyName: '🚀'.repeat(60),
customerName: '漢'.repeat(60),
invoiceNumber: '2621',
invoiceDate: '2026-07-21',
})
expect(Buffer.byteLength(filename, 'utf8')).toBeLessThanOrEqual(255)
expect(filename).toMatch(/Faktura nr 2621 20260721\.pdf$/)
})
})
@@ -45,6 +45,42 @@ vi.mock('@/lib/email/service', () => ({
}),
}))
const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
subject: string
html: string
text: string
replyTo?: string
fromName?: string
filename: string
pdfBuffer: Buffer
}) => ({
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
subject: input.subject,
html: input.html,
text: input.text,
replyTo: input.replyTo,
fromName: input.fromName,
attachments: [{
filename: input.filename,
content: input.pdfBuffer,
contentType: 'application/pdf',
}],
})),
deliveryId: 'delivery-1',
documentId: 'document-1',
}))
const mockReserveInvoiceDelivery = vi.fn().mockResolvedValue('delivery-1')
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
InvoiceDeliverySnapshotError: class InvoiceDeliverySnapshotError extends Error {},
reserveInvoiceDelivery: (...args: unknown[]) => mockReserveInvoiceDelivery(...args),
sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
@@ -74,6 +110,7 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
linkToJournalEntry: vi.fn().mockResolvedValue(undefined),
}))
describe('computeNextRunDate', () => {
@@ -175,8 +212,15 @@ describe('executeRecurringSchedule auto-send', () => {
const client = supabase as unknown as SupabaseClient
const today = new Date('2026-07-06T06:30:00Z')
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual' })
const customer = makeCustomer({
id: 'cust-1',
name: 'Kund ÅÄÖ AB',
email: 'kund@test.se',
})
const company = makeCompanySettings({
company_name: 'Oppy Sverige',
accounting_method: 'accrual',
})
function makeSchedule() {
return {
@@ -223,6 +267,7 @@ describe('executeRecurringSchedule auto-send', () => {
return {
id: 'inv-1',
invoice_number: 'F-1',
invoice_date: '2026-07-06',
status: 'draft',
document_type: 'invoice',
currency: 'SEK',
@@ -283,6 +328,9 @@ 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(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
expect.anything(),
@@ -302,6 +350,11 @@ describe('executeRecurringSchedule auto-send', () => {
expect(mockInvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(expect.objectContaining({
attachments: [expect.objectContaining({
filename: 'Oppy Sverige x Kund ÅÄÖ AB Faktura nr F-1 20260706.pdf',
})],
}))
})
it('a payment link failure never blocks the send', async () => {
+256
View File
@@ -0,0 +1,256 @@
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 type { InvoiceDelivery } from '@/types'
const PDF_CONTENT_TYPE = 'application/pdf'
export class InvoiceDeliverySnapshotError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvoiceDeliverySnapshotError'
}
}
export interface TrackedInvoiceEmailInput {
supabase: SupabaseClient
emailService: EmailService
companyId: string
userId: string
invoiceId: string
deliveryId: string
to: string | string[]
cc?: string | string[]
replyTo?: string
fromName?: string
subject: string
html: string
text: string
filename: string
pdfBuffer: Buffer
}
export interface TrackedInvoiceEmailResult extends SendEmailResult {
deliveryId: string
documentId: string
trackingWarning?: 'finalize_failed' | 'failure_record_failed' | 'failure_cleanup_failed'
}
function addresses(value?: string | string[]): string[] {
if (!value) return []
return Array.isArray(value) ? value : [value]
}
/**
* Persist a reusable delivery attempt before allocating an invoice number.
* The unique preparing row is also the concurrency lock for one invoice send.
*/
export async function reserveInvoiceDelivery(args: {
supabase: SupabaseClient
companyId: string
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()
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
}
throw new InvoiceDeliverySnapshotError(
`Failed to reserve invoice delivery: ${error?.message || 'unknown error'}`,
)
}
export async function sendTrackedInvoiceEmail(
input: TrackedInvoiceEmailInput,
): Promise<TrackedInvoiceEmailResult> {
const {
supabase,
emailService,
companyId,
userId,
invoiceId,
deliveryId,
to,
cc,
replyTo,
fromName,
subject,
html,
text,
filename,
pdfBuffer,
} = input
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
const document = await uploadDocument(
supabase,
userId,
companyId,
{
name: filename,
buffer: pdfArrayBuffer,
type: PDF_CONTENT_TYPE,
},
{ 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()
if (deliveryError || !delivery) {
try {
await deleteDocument(supabase, companyId, document.id)
} catch {
// Best-effort cleanup only. The send must remain blocked even if the
// unlinked archive cannot be removed after a snapshot insert failure.
}
throw new InvoiceDeliverySnapshotError(
`Failed to persist invoice delivery snapshot: ${deliveryError?.message || 'unknown error'}`,
)
}
const emailOptions: SendEmailOptions = {
to,
cc,
subject,
html,
text,
replyTo,
fromName,
attachments: [
{
filename,
content: pdfBuffer,
contentType: PDF_CONTENT_TYPE,
},
],
}
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')
let cleanupFailed = false
if (!failureRecordError) {
try {
const cleanup = await deleteDocument(supabase, companyId, document.id)
cleanupFailed = !cleanup.ok
} catch {
cleanupFailed = true
}
}
return {
...result,
deliveryId: delivery.id,
documentId: document.id,
...(failureRecordError
? { trackingWarning: 'failure_record_failed' as const }
: cleanupFailed
? { trackingWarning: 'failure_cleanup_failed' as const }
: {}),
}
}
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')
return {
...result,
deliveryId: delivery.id,
documentId: document.id,
...(finalizeError ? { trackingWarning: 'finalize_failed' as const } : {}),
}
}
export async function recordManualInvoiceDelivery(args: {
supabase: SupabaseClient
companyId: string
userId: string
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()
if (error || !data) {
throw new InvoiceDeliverySnapshotError(
`Failed to persist manual invoice delivery: ${error?.message || 'unknown error'}`,
)
}
return data as InvoiceDelivery
}
+85
View File
@@ -0,0 +1,85 @@
import type { InvoiceDocumentType } from '@/types'
const MAX_NAME_PART_LENGTH = 60
const MAX_NUMBER_PART_LENGTH = 40
const MAX_FILENAME_BYTES = 255
interface InvoicePdfFilenameInput {
companyName?: string | null
customerName?: string | null
invoiceNumber?: string | null
invoiceId?: string | null
invoiceDate?: string | null
documentType?: InvoiceDocumentType | null
isCreditNote?: boolean
}
function safeFilenamePart(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = (value ?? '')
.toWellFormed()
.normalize('NFC')
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/[ .]+$/g, '')
.trim()
if (!normalized) return fallback
return Array.from(normalized).slice(0, maxLength).join('').replace(/[ .]+$/g, '') || fallback
}
function documentLabel(documentType: InvoiceDocumentType, isCreditNote: boolean): string {
if (isCreditNote) return 'Kreditfaktura'
if (documentType === 'proforma') return 'Proformafaktura'
if (documentType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
function utf8ByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
function fitFilename(companyName: string, customerName: string, suffix: string): string {
const company = Array.from(companyName)
const customer = Array.from(customerName)
const build = () => `${company.join('')} x ${customer.join('')} ${suffix}`
while (utf8ByteLength(build()) > MAX_FILENAME_BYTES && (company.length > 1 || customer.length > 1)) {
if (utf8ByteLength(company.join('')) >= utf8ByteLength(customer.join('')) && company.length > 1) {
company.pop()
} else if (customer.length > 1) {
customer.pop()
} else {
company.pop()
}
}
return build()
}
/**
* Build a descriptive, cross-platform-safe PDF filename for an invoice document.
*
* Example: `Oppy x Kund AB Faktura nr 2621 20260721.pdf`.
*/
export function invoicePdfFilename({
companyName,
customerName,
invoiceNumber,
invoiceId,
invoiceDate,
documentType = 'invoice',
isCreditNote = false,
}: InvoicePdfFilenameInput): string {
const company = safeFilenamePart(companyName, 'Företag', MAX_NAME_PART_LENGTH)
const customer = safeFilenamePart(customerName, 'Kund', MAX_NAME_PART_LENGTH)
const label = documentLabel(documentType ?? 'invoice', isCreditNote)
// The cross-platform filename is descriptive only. The invoice body retains
// the authoritative number and credit-note reference, including separators.
const number = invoiceNumber
? `nr ${safeFilenamePart(invoiceNumber, 'okänd', MAX_NUMBER_PART_LENGTH)}`
: `utkast-${safeFilenamePart(invoiceId?.slice(0, 8), 'utan-nummer', MAX_NUMBER_PART_LENGTH)}`
const compactDate = (invoiceDate ?? '').replace(/[^0-9]/g, '').slice(0, 8)
const suffix = [label, number, compactDate].filter(Boolean).join(' ') + '.pdf'
return fitFilename(company, customer, suffix)
}
+76 -28
View File
@@ -16,6 +16,7 @@ import { eventBus } from '@/lib/events'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
@@ -34,7 +35,11 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import {
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import { createLogger } from '@/lib/logger'
import type {
Invoice,
@@ -452,6 +457,22 @@ async function sendInvoiceFromSchedule(
throw new Error('company settings missing: cannot send invoice')
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId,
userId,
invoiceId: invoice.id,
})
} catch (err) {
log.error('failed to reserve recurring invoice delivery', err as Error, {
invoiceId: invoice.id,
companyId,
})
return false
}
const items = (invoice.items || []).slice().sort((a, b) => a.sort_order - b.sort_order)
// Auto-create an online payment link (extension-provided, e.g. Stripe) so
@@ -492,22 +513,53 @@ async function sendInvoiceFromSchedule(
}),
)
const emailData = { invoice, customer: invoice.customer, company }
const filename = `faktura-${invoice.invoice_number}.pdf`
const emailData = { invoice: renderableInvoice, customer: invoice.customer, company }
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: invoice.customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
})
const ccAddress = company.email || undefined
const result = await emailService.sendEmail({
to: invoice.customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name ?? undefined,
attachments: [
{ filename, content: pdfBuffer, contentType: 'application/pdf' },
],
})
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId,
userId,
invoiceId: invoice.id,
deliveryId,
to: invoice.customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name ?? undefined,
filename,
pdfBuffer,
})
} catch (err) {
log.error('failed to persist recurring invoice delivery before send', err as Error, {
invoiceId: invoice.id,
})
return false
}
if (result.trackingWarning) {
log.error(
'recurring invoice delivery snapshot requires reconciliation',
new Error(result.trackingWarning),
{ invoiceId: invoice.id, deliveryId: result.deliveryId },
)
}
if (!result.success) {
log.error(
@@ -551,19 +603,15 @@ async function sendInvoiceFromSchedule(
}
}
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(
supabase,
userId,
companyId,
{ name: filename, buffer: pdfArrayBuffer, type: 'application/pdf' },
{ upload_source: 'system', journal_entry_id: journalEntryId },
)
} catch (err) {
log.error('failed to archive recurring invoice PDF', err as Error, {
invoiceId: invoice.id,
})
if (journalEntryId) {
try {
await linkToJournalEntry(supabase, companyId, result.documentId, journalEntryId)
} catch (err) {
log.error('failed to link recurring invoice PDF to journal entry', err as Error, {
invoiceId: invoice.id,
documentId: result.documentId,
})
}
}
await eventBus.emit({
@@ -54,6 +54,13 @@ vi.mock('@/lib/transactions/categorize-core', async () => {
}
})
const mockRecordManualInvoiceDelivery = vi.fn().mockResolvedValue({ id: 'delivery-1' })
vi.mock('@/lib/invoices/invoice-deliveries', () => ({
recordManualInvoiceDelivery: (...args: unknown[]) => mockRecordManualInvoiceDelivery(...args),
reserveInvoiceDelivery: vi.fn().mockResolvedValue('delivery-1'),
sendTrackedInvoiceEmail: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { parseSIEFile } from '@/lib/import/sie-parser'
@@ -173,6 +180,43 @@ describe('commitPendingOperation: credit-note issuance guard', () => {
expect(result.http_status).toBe(409)
expect(result.error).toContain('Credit notes must be issued')
})
it('records delivery history when a regular invoice is marked as sent', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: makeInvoice({
id: 'invoice-1',
status: 'draft',
invoice_number: 'F-2026001',
credited_invoice_id: null,
}),
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({
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('committed')
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase,
companyId: 'company-1',
userId: 'user-1',
invoiceId: 'invoice-1',
})
})
})
// ─── post_annual_depreciation ───────────────────────────────────────
+103 -25
View File
@@ -69,11 +69,17 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
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 { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
recordManualInvoiceDelivery,
reserveInvoiceDelivery,
sendTrackedInvoiceEmail,
} from '@/lib/invoices/invoice-deliveries'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
@@ -1543,6 +1549,23 @@ async function commitSendInvoice(
}
}
let deliveryId: string
try {
deliveryId = await reserveInvoiceDelivery({
supabase,
companyId,
userId,
invoiceId,
})
} catch (err) {
log.error('failed to reserve invoice delivery before agent number assignment', err as Error, {
companyId,
userId,
invoiceId,
})
return { error: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.', status: 500 }
}
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
@@ -1570,25 +1593,58 @@ async function commitSendInvoice(
)
const isCreditNote = !!invoice.credited_invoice_id
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) filename = `kreditfaktura-${invoice.invoice_number}.pdf`
else if (docType === 'proforma') filename = `proformafaktura-${invoice.invoice_number}.pdf`
else if (docType === 'delivery_note') filename = `foljesedel-${invoice.invoice_number}.pdf`
else filename = `faktura-${invoice.invoice_number}.pdf`
const filename = invoicePdfFilename({
companyName: company.company_name,
customerName: customer.name,
invoiceNumber: invoice.invoice_number,
invoiceId: invoice.id,
invoiceDate: invoice.invoice_date,
documentType: invoice.document_type,
isCreditNote,
})
const ccAddress = company.email || userEmail
const emailData = { invoice: invoice as Invoice, customer, company: company as CompanySettings }
const result = await emailService.sendEmail({
to: customer.email,
cc: ccAddress,
subject: generateInvoiceEmailSubject(emailData),
html: generateInvoiceEmailHtml(emailData),
text: generateInvoiceEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name,
attachments: [{ filename, content: pdfBuffer, contentType: 'application/pdf' }],
})
const emailData = { invoice: renderableInvoice, customer, company: company as CompanySettings }
const subject = generateInvoiceEmailSubject(emailData)
const html = generateInvoiceEmailHtml(emailData)
const text = generateInvoiceEmailText(emailData)
let result
try {
result = await sendTrackedInvoiceEmail({
supabase,
emailService,
companyId,
userId,
invoiceId,
deliveryId,
to: customer.email,
cc: ccAddress,
subject,
html,
text,
replyTo: company.email || undefined,
fromName: company.company_name,
filename,
pdfBuffer,
})
} catch (err) {
log.error('failed to persist invoice delivery snapshot before agent send', err as Error, {
companyId,
userId,
invoiceId,
})
return { error: 'Utskicksinformationen kunde inte sparas. Ingen e-post skickades.', status: 500 }
}
if (result.trackingWarning) {
log.warn('agent invoice delivery snapshot requires reconciliation', {
companyId,
userId,
invoiceId,
deliveryId: result.deliveryId,
warning: result.trackingWarning,
})
}
if (!result.success) return { error: `Failed to send email: ${result.error}`, status: 500 }
@@ -1610,18 +1666,22 @@ async function commitSendInvoice(
}
}
if (isRealInvoice) {
if (isRealInvoice && createdJournalEntryId) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, userId, companyId, {
name: filename, buffer: pdfArrayBuffer, type: 'application/pdf',
}, { upload_source: 'system', journal_entry_id: createdJournalEntryId })
await linkToJournalEntry(supabase, companyId, result.documentId, createdJournalEntryId)
} catch { /* non-blocking */ }
}
await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId, companyId } })
return { data: { message: `Invoice ${invoice.invoice_number} sent to ${customer.email}` } }
return {
data: {
message: `Invoice ${invoice.invoice_number} sent to ${customer.email}`,
...(result.trackingWarning
? { warning: 'Delivery history requires reconciliation.' }
: {}),
},
}
}
async function commitMarkInvoiceSent(
@@ -1659,6 +1719,18 @@ async function commitMarkInvoiceSent(
if (updateError) return { error: 'Failed to update invoice status', status: 500 }
let deliveryHistoryWarning: string | undefined
try {
await recordManualInvoiceDelivery({ supabase, companyId, userId, invoiceId })
} catch (err) {
log.error('failed to persist manual invoice delivery from pending operation', err as Error, {
companyId,
userId,
invoiceId,
})
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()
@@ -1681,7 +1753,13 @@ async function commitMarkInvoiceSent(
}
}
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
return {
data: {
status: 'sent',
journal_entry_id: journalEntryId,
...(deliveryHistoryWarning ? { warning: deliveryHistoryWarning } : {}),
},
}
}
async function commitMatchTransactionInvoice(
+3
View File
@@ -792,6 +792,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
{ name: 'invoice_items', file: 'invoice_items.json', via: { parent: 'invoices', fk: 'invoice_id' } },
{ name: 'invoice_payments', file: 'invoice_payments.json', orderBy: 'payment_date' },
{ name: 'invoice_reminders', file: 'invoice_reminders.json' },
// Delivery metadata proves which recipient received the archived PDF and
// when, so it is räkenskapsinformation alongside the invoice itself.
{ name: 'invoice_deliveries', file: 'invoice_deliveries.json', orderBy: 'created_at' },
{ name: 'recurring_invoice_schedules', file: 'recurring_invoice_schedules.json' },
// Supplier invoicing
{ name: 'supplier_invoices', file: 'supplier_invoices.json', orderBy: 'invoice_date' },
+23
View File
@@ -2883,6 +2883,28 @@
"agreement_ref_label": "Agreement reference",
"created_at": "Created {date}",
"sent_at_suffix": " • Sent {date}",
"delivery_history_title": "Delivery history",
"delivery_history_description": "See when the invoice was sent, who received it, and the exact message and PDF that were used.",
"delivery_history_legacy_title": "Delivery details are unavailable",
"delivery_history_legacy_description": "This invoice was sent before delivery history was recorded. Its time, recipients, and exact content cannot be shown reliably.",
"delivery_channel_email": "Sent by email",
"delivery_channel_manual": "Manually marked as sent",
"delivery_status_pending": "In progress",
"delivery_status_sent": "Sent",
"delivery_status_failed": "Failed",
"delivery_status_marked_sent": "Manual",
"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_reply_to_label": "Reply to",
"delivery_from_label": "Sender name",
"delivery_subject_label": "Subject",
"delivery_message_label": "Message",
"delivery_open_pdf": "Open attached PDF: {filename}",
"delivery_pdf_fallback": "invoice.pdf",
"delivery_provider_label": "Email provider",
"delivery_message_id_label": "Message ID",
"delivery_error_label": "Error",
"convert_to_invoice": "Convert to invoice",
"send_via_email": "Send via email",
"send_via_email_and_book": "Send via email and post",
@@ -4370,6 +4392,7 @@
"source_skatteverket": "Skatteverket ({count})",
"search_placeholder": "Search transactions...",
"no_search_results": "No transactions match your search.",
"source_empty": "The selected source has no transactions. Choose All to show the other sources.",
"skv_reconnect_title": "The Skatteverket connection needs to be renewed",
"skv_reconnect_body": "Tax account transactions are not fetched until you reconnect with BankID and approve all permissions.",
"skv_reconnect_cta": "Reconnect",
+23
View File
@@ -2883,6 +2883,28 @@
"agreement_ref_label": "Avtalsreferens",
"created_at": "Skapad {date}",
"sent_at_suffix": " • Skickad {date}",
"delivery_history_title": "Utskickshistorik",
"delivery_history_description": "Se när fakturan skickades, till vem och exakt vilket meddelande och vilken PDF som användes.",
"delivery_history_legacy_title": "Detaljer saknas för detta utskick",
"delivery_history_legacy_description": "Fakturan skickades innan utskickshistorik började sparas. Tidpunkt, mottagare och exakt innehåll kan därför inte visas säkert.",
"delivery_channel_email": "Skickad via e-post",
"delivery_channel_manual": "Markerad som skickad manuellt",
"delivery_status_pending": "Pågår",
"delivery_status_sent": "Skickad",
"delivery_status_failed": "Misslyckad",
"delivery_status_marked_sent": "Manuell",
"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_reply_to_label": "Svara till",
"delivery_from_label": "Avsändarnamn",
"delivery_subject_label": "Ämne",
"delivery_message_label": "Meddelande",
"delivery_open_pdf": "Öppna bifogad PDF: {filename}",
"delivery_pdf_fallback": "faktura.pdf",
"delivery_provider_label": "E-posttjänst",
"delivery_message_id_label": "Meddelande-id",
"delivery_error_label": "Fel",
"convert_to_invoice": "Konvertera till faktura",
"send_via_email": "Skicka via e-post",
"send_via_email_and_book": "Skicka via e-post och bokför",
@@ -4370,6 +4392,7 @@
"source_skatteverket": "Skatteverket ({count})",
"search_placeholder": "Sök transaktion...",
"no_search_results": "Inga transaktioner matchar din sökning.",
"source_empty": "Den valda källan har inga transaktioner. Välj Alla för att visa övriga källor.",
"skv_reconnect_title": "Anslutningen till Skatteverket behöver förnyas",
"skv_reconnect_body": "Skattekontots transaktioner hämtas inte förrän du anslutit igen med BankID och godkänt alla behörigheter.",
"skv_reconnect_cta": "Anslut igen",
@@ -0,0 +1,238 @@
-- Durable customer-invoice delivery history.
--
-- One row represents one delivery attempt. Email payload fields and the exact
-- archived PDF are captured before the provider call, then only the pending
-- status may transition to sent or failed. Manual marks deliberately contain
-- no recipient or payload because Accounted did not perform that delivery.
CREATE TABLE public.invoice_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
invoice_id uuid NOT NULL REFERENCES public.invoices(id) ON DELETE RESTRICT,
channel text NOT NULL CHECK (channel IN ('email', 'manual')),
status text NOT NULL CHECK (status IN ('pending', 'sent', 'failed', 'marked_sent')),
to_addresses text[] NOT NULL DEFAULT '{}',
cc_addresses text[] NOT NULL DEFAULT '{}',
reply_to text,
from_name text,
subject text,
body_text text,
body_html text,
provider text,
provider_message_id text,
error_code text,
document_attachment_id uuid REFERENCES public.document_attachments(id) ON DELETE RESTRICT,
attachment_filename text,
attachment_content_type text,
attachment_sha256 text,
sent_at timestamptz,
failed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT invoice_deliveries_payload_shape CHECK (
(
channel = 'email'
AND status IN ('pending', 'sent', 'failed')
AND cardinality(to_addresses) > 0
AND subject IS NOT NULL
AND body_text IS NOT NULL
AND body_html IS NOT NULL
AND document_attachment_id IS NOT NULL
AND attachment_filename IS NOT NULL
AND attachment_content_type IS NOT NULL
AND attachment_sha256 IS NOT NULL
)
OR
(
channel = 'manual'
AND status = 'marked_sent'
AND cardinality(to_addresses) = 0
AND cardinality(cc_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
)
),
CONSTRAINT invoice_deliveries_terminal_timestamps CHECK (
(status IN ('sent', 'marked_sent') AND sent_at IS NOT NULL AND failed_at IS NULL)
OR (status = 'failed' AND sent_at IS NULL AND failed_at IS NOT NULL)
OR (status = 'pending' AND sent_at IS NULL AND failed_at IS NULL)
)
);
COMMENT ON TABLE public.invoice_deliveries IS
'Immutable delivery attempts for customer invoices, including exact email payload and archived PDF snapshot.';
COMMENT ON COLUMN public.invoice_deliveries.body_html IS
'Exact HTML alternative submitted to the email provider. Never render directly in the dashboard DOM.';
COMMENT ON COLUMN public.invoice_deliveries.document_attachment_id IS
'Exact PDF submitted as the email attachment. The FK and deletion trigger keep sent snapshots in the WORM archive.';
ALTER TABLE public.invoice_deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoice_deliveries_select
ON public.invoice_deliveries FOR SELECT TO public
USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY invoice_deliveries_insert
ON public.invoice_deliveries FOR INSERT TO public
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
CREATE POLICY invoice_deliveries_update
ON public.invoice_deliveries FOR UPDATE TO public
USING (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
)
WITH CHECK (
company_id = public.current_active_company_id()
AND public.current_user_can_write()
);
-- No DELETE policy. Delivery evidence is append-only, and the trigger below
-- also blocks service-role or future policy bypasses.
CREATE INDEX idx_invoice_deliveries_invoice_created
ON public.invoice_deliveries (invoice_id, created_at DESC);
CREATE INDEX idx_invoice_deliveries_company_created
ON public.invoice_deliveries (company_id, created_at DESC);
CREATE UNIQUE INDEX idx_invoice_deliveries_provider_message
ON public.invoice_deliveries (provider, provider_message_id)
WHERE provider IS NOT NULL AND provider_message_id IS NOT NULL;
CREATE OR REPLACE FUNCTION public.validate_invoice_delivery_tenant()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.invoices i
WHERE i.id = NEW.invoice_id
AND i.company_id = NEW.company_id
) THEN
RAISE EXCEPTION 'invoice delivery invoice/company mismatch'
USING ERRCODE = '23514';
END IF;
IF NEW.document_attachment_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM public.document_attachments da
WHERE da.id = NEW.document_attachment_id
AND da.company_id = NEW.company_id
) THEN
RAISE EXCEPTION 'invoice delivery document/company mismatch'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER validate_invoice_delivery_tenant
BEFORE INSERT OR UPDATE ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.validate_invoice_delivery_tenant();
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
RAISE EXCEPTION 'invoice delivery history is immutable'
USING ERRCODE = '23514';
END IF;
IF OLD.status <> 'pending' THEN
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END IF;
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.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.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
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.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_invoice_delivery_update_immutability
BEFORE UPDATE ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.enforce_invoice_delivery_immutability();
CREATE TRIGGER enforce_invoice_delivery_delete_immutability
BEFORE DELETE ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.enforce_invoice_delivery_immutability();
CREATE OR REPLACE FUNCTION public.block_sent_invoice_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.invoice_deliveries d
WHERE d.document_attachment_id = OLD.id
AND d.status = 'sent'
) THEN
RAISE EXCEPTION 'retention: document is the exact PDF sent with a customer invoice'
USING ERRCODE = '23514';
END IF;
RETURN OLD;
END;
$$;
CREATE TRIGGER block_sent_invoice_document_deletion
BEFORE DELETE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.block_sent_invoice_document_deletion();
CREATE TRIGGER set_updated_at_invoice_deliveries
BEFORE UPDATE ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,456 @@
-- Harden customer-invoice delivery history after security and compliance review.
--
-- Delivery preparation is persisted before invoice number allocation. Exact
-- provider payloads remain immutable, while expired personal data is redacted
-- after the statutory accounting retention period. Audit rows contain metadata
-- only and never duplicate recipients or message bodies.
ALTER TABLE public.invoice_deliveries
DROP CONSTRAINT invoice_deliveries_company_id_fkey,
ADD CONSTRAINT invoice_deliveries_company_id_fkey
FOREIGN KEY (company_id) REFERENCES public.companies(id) ON DELETE RESTRICT,
DROP CONSTRAINT invoice_deliveries_user_id_fkey,
ADD CONSTRAINT invoice_deliveries_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE RESTRICT;
ALTER TABLE public.invoice_deliveries
ADD COLUMN retention_expires_at date,
ADD COLUMN pii_redacted_at timestamptz;
-- Keep the WORM trigger active during backfill. This temporary function allows
-- exactly the new retention date to be initialized and preserves every existing
-- delivery field. It is replaced by the final state machine below.
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
RAISE EXCEPTION 'invoice delivery history is immutable'
USING ERRCODE = '23514';
END IF;
IF OLD.retention_expires_at IS NULL
AND NEW.retention_expires_at IS NOT NULL
AND (to_jsonb(NEW) - 'retention_expires_at' - 'updated_at')
IS NOT DISTINCT FROM
(to_jsonb(OLD) - 'retention_expires_at' - 'updated_at')
THEN
RETURN NEW;
END IF;
IF OLD.status <> 'pending' THEN
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
USING ERRCODE = '23514';
END IF;
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.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.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
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.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'invoice delivery payload is immutable'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
UPDATE public.invoice_deliveries d
SET retention_expires_at = COALESCE(
(
SELECT fp.retention_expires_at
FROM public.invoices i
JOIN public.fiscal_periods fp
ON fp.company_id = i.company_id
AND i.invoice_date BETWEEN fp.period_start AND fp.period_end
WHERE i.id = d.invoice_id
ORDER BY fp.period_end DESC
LIMIT 1
),
(
SELECT make_date(extract(year FROM i.invoice_date)::integer + 8, 1, 1)
FROM public.invoices i
WHERE i.id = d.invoice_id
)
);
ALTER TABLE public.invoice_deliveries
ALTER COLUMN retention_expires_at SET NOT NULL;
CREATE OR REPLACE FUNCTION public.set_invoice_delivery_retention_expiry()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NEW.retention_expires_at IS NULL THEN
SELECT COALESCE(
(
SELECT fp.retention_expires_at
FROM public.fiscal_periods fp
WHERE fp.company_id = i.company_id
AND i.invoice_date BETWEEN fp.period_start AND fp.period_end
ORDER BY fp.period_end DESC
LIMIT 1
),
make_date(extract(year FROM i.invoice_date)::integer + 8, 1, 1)
)
INTO NEW.retention_expires_at
FROM public.invoices i
WHERE i.id = NEW.invoice_id
AND i.company_id = NEW.company_id;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER set_invoice_delivery_retention_expiry
BEFORE INSERT ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.set_invoice_delivery_retention_expiry();
ALTER TABLE public.invoice_deliveries
DROP CONSTRAINT invoice_deliveries_status_check,
ADD CONSTRAINT invoice_deliveries_status_check
CHECK (status IN ('preparing', 'pending', 'sent', 'failed', 'marked_sent')),
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 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 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 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
)
),
DROP CONSTRAINT invoice_deliveries_terminal_timestamps,
ADD CONSTRAINT invoice_deliveries_terminal_timestamps CHECK (
(status IN ('sent', 'marked_sent') AND sent_at IS NOT NULL AND failed_at IS NULL)
OR (status = 'failed' AND sent_at IS NULL AND failed_at IS NOT NULL)
OR (status IN ('preparing', 'pending') AND sent_at IS NULL AND failed_at IS NULL)
);
CREATE UNIQUE INDEX idx_invoice_deliveries_preparing_invoice
ON public.invoice_deliveries (company_id, invoice_id)
WHERE status = 'preparing';
CREATE OR REPLACE FUNCTION public.invoice_delivery_audit_state(
delivery public.invoice_deliveries
)
RETURNS jsonb
LANGUAGE sql
IMMUTABLE
SET search_path = public
AS $$
SELECT jsonb_build_object(
'id', delivery.id,
'company_id', delivery.company_id,
'user_id', delivery.user_id,
'invoice_id', delivery.invoice_id,
'channel', delivery.channel,
'status', delivery.status,
'document_attachment_id', delivery.document_attachment_id,
'provider', delivery.provider,
'error_code', delivery.error_code,
'sent_at', delivery.sent_at,
'failed_at', delivery.failed_at,
'retention_expires_at', delivery.retention_expires_at,
'pii_redacted_at', delivery.pii_redacted_at,
'created_at', delivery.created_at
)
$$;
CREATE OR REPLACE FUNCTION public.write_invoice_delivery_audit()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
new_state,
description
) VALUES (
CASE WHEN TG_OP = 'INSERT' THEN NEW.user_id ELSE COALESCE(NEW.user_id, OLD.user_id) END,
CASE WHEN TG_OP = 'INSERT' THEN NEW.company_id ELSE COALESCE(NEW.company_id, OLD.company_id) END,
TG_OP,
'invoice_deliveries',
CASE WHEN TG_OP = 'INSERT' THEN NEW.id ELSE COALESCE(NEW.id, OLD.id) END,
auth.uid(),
CASE WHEN TG_OP = 'UPDATE' THEN public.invoice_delivery_audit_state(OLD) END,
public.invoice_delivery_audit_state(NEW),
'Invoice delivery metadata changed. Recipient and message content excluded.'
);
RETURN NEW;
END;
$$;
CREATE TRIGGER audit_invoice_delivery_metadata
AFTER INSERT OR UPDATE ON public.invoice_deliveries
FOR EACH ROW EXECUTE FUNCTION public.write_invoice_delivery_audit();
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.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 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 = '{}',
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;
$$;
REVOKE ALL ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
SELECT cron.schedule(
'redact-expired-invoice-delivery-pii',
'15 3 * * *',
$$SELECT public.redact_expired_invoice_delivery_pii()$$
);
COMMENT ON COLUMN public.invoice_deliveries.retention_expires_at IS
'First date after the BFL 7 kap statutory minimum. Recipient and message PII is redacted on or after this date.';
COMMENT ON FUNCTION public.redact_expired_invoice_delivery_pii() IS
'Daily storage-limitation control that removes invoice delivery PII after the statutory retention period.';
NOTIFY pgrst, 'reload schema';
+32
View File
@@ -997,6 +997,38 @@ export interface Invoice {
payments?: InvoicePayment[]
}
export type InvoiceDeliveryChannel = 'email' | 'manual'
export type InvoiceDeliveryStatus = 'preparing' | 'pending' | 'sent' | 'failed' | 'marked_sent'
export interface InvoiceDelivery {
id: string
company_id: string
user_id: string | null
invoice_id: string
channel: InvoiceDeliveryChannel
status: InvoiceDeliveryStatus
to_addresses: string[]
cc_addresses: string[]
reply_to: string | null
from_name: string | null
subject: string | null
body_text: string | null
body_html: string | null
provider: string | null
provider_message_id: string | null
error_code: string | null
document_attachment_id: string | null
attachment_filename: string | null
attachment_content_type: string | null
attachment_sha256: string | null
sent_at: string | null
failed_at: string | null
retention_expires_at: string
pii_redacted_at: string | null
created_at: string
updated_at: string
}
// Invoice Item
export interface InvoiceItem {
id: string