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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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> Så 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SendEmailOptions {
|
||||
|
||||
export interface SendEmailResult {
|
||||
success: boolean
|
||||
provider?: string
|
||||
messageId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user