diff --git a/DECISIONS.md b/DECISIONS.md index 075fd5df..bb01e34f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1076,6 +1076,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-18] AGI receipt fallback (#1597): GET /agi/status serves the signed record from agi_declarations (kvittensnummer, response_data.signeradAv/signeradTid, submitted_at) only when the agi_submission_{period} cache is absent, and the declaration-sourced record deliberately carries NO salaryRunId: the period row is UNIQUE per company+period and regenerating a correction repoints its salary_run_id at the correction run while the stored kvittens still belongs to the original, so trusting the column would render the correction as filed with a superseded receipt. Ownership rests on signeradTid/submittedAt vs the run's agi_submitted_at stamp (same value) plus updatedAt = submitted_at, which predates any later correction's XML. Cache present still wins because it is the only place the in-flight states live. Rejected: a second client fetch in AGIPanel (two sources of truth for one card) and merging both records in the route (mixes another declaration's fields into an in-flight state). [2026-08-18] Trial seeding trigger widened to every PAID capability (20260818170000): the 2026-06-29 trigger hardcoded the four launch keys while PAID_CAPABILITIES grew to seven (stripe_payments, woocommerce_sync, shopify_sync); the Stripe webhook seeds from the constant, so payers had all seven and trialers four (prod 2026-08-18: 226/230 active trialers lacked stripe_payments). Fixed by redefining the function with all seven and mirroring existing trial bank_sync grants for the three keys; the pg test now compares the seeded set against PAID_CAPABILITIES itself so the two cannot drift silently again. Rejected: generating the VALUES list from the TS constant at build time (no codegen path into migrations exists; a test that pins them together is the cheaper guard). [2026-08-18] Invoice ROT/RUT personnummer surfaces (detail page, invoice PDF, preview PDF, editor kept-hint) switch to the payroll mask convention YYYYMMDD-XXXX (birth date visible, last four hidden), computed on read from deduction_personnummer_encrypted via lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). The browser gets the mask from GET /api/invoices/[id]/rot-rut and never both the mask and the last four (that is the full number); v1 REST and MCP keep deduction_personnummer_last4 for compatibility (an additive deduction_personnummer_masked is a possible follow-up). InvoicePDF derives the mask itself when the caller passes the stored row, so none of the 11 render call sites can silently drop the personnummer; the preview route passes an already-masked value since it only has plaintext. The separate Skattereduktion card on the invoice detail page is folded into Detaljer as plain rows (Personnummer, Fastighet, Skattereduktion status with the begäran lifecycle) per founder decision 2026-08-18: it duplicated the totals block. +[2026-08-19] Betald-stämpel / betalningsbekräftelse (#1693): the paid copy is always a fresh re-render of the faktura (GET /api/invoices/[id]/pdf?variant=paid, refused unless status = paid, filename Betalningsbekraftelse-.pdf, invoice-pdf-source reason 'payment_confirmation' so the UI caveats it like any re-render), and the archived original in invoice_deliveries is never read, replaced or relinked: it is the invoice as sent (unpaid) and a different document. The send action (POST /send-payment-confirmation) reuses the email service, recipient routing and branding of the invoice send but none of its side effects (no status flip, no sent_at, no delivery row, no journal write) and logs the send via the normal logger: invoice_deliveries has no kind column and an 'email'/'sent' row there would become the archived original to resolveInvoicePdfSource; a typed history entry needs its own migration and is a follow-up. The two actions live inside the Betald card on the detail page, not in the header row (founder decluttered it in #1699); partially_paid gets only the Betalt / Att betala rows in the PDF and no banner or button, because the customer still owes money. The template keys the stamp off the stored status, so every re-render of a paid invoice carries it; credit notes and proformas are unchanged. [2026-08-18] Migration-reset archive access follows the immutable reset link from a currently owned replacement instead of requiring mutable membership on the retained source: freezing archived company_members would break legitimate team removal and account anonymization, while replacement ownership plus an archived linked source keeps retained accounting information reachable without reactivating it. [2026-08-18] Migration-reset replacements preserve next_invoice_number and next_arrival_number from the retained source: the replacement is the same legal entity, and a non-default counter can represent imported or previously allocated numbering even when no invoice row remains; restarting at 1 risks reuse or an unexplained break, while pg-real pins continuity at values above 1. [2026-08-18] Migration-reset eligibility treats every persisted Skatteverket VAT `submission_*` workflow row as authority interaction evidence, not only successful audit-log rows: historical direct locks stored the signing state without auditing it, and Accounted cannot observe whether a user completed BankID signing outside the app. Unsigned drafts must be removed through the product; locked or uncertain state fails closed and is escalated. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 7c634565..a36380bd 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -22,10 +22,12 @@ import { canCopyInvoice } from '@/lib/invoices/copy-invoice' import { invoiceDocumentCaveat, invoiceRerenderUrl, + paymentConfirmationPdfSource, resolveInvoicePdfSource, type InvoicePdfRerenderReason, type InvoicePdfSource, } from '@/lib/invoices/invoice-pdf-source' +import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation' import { contentDispositionFilename } from '@/lib/api/content-disposition' import { Loader2, @@ -94,6 +96,7 @@ const RERENDER_CAVEAT_KEYS: Record< sent_outside_accounted: 'pdf_rerender_reason_sent_outside', no_archived_copy: 'pdf_rerender_reason_no_archive', archive_unreachable: 'pdf_rerender_reason_archive_unreachable', + payment_confirmation: 'pdf_rerender_reason_payment_confirmation', } // A line is periodiserad when both period dates are set: the revenue was @@ -188,6 +191,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [isUpdating, setIsUpdating] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [isDownloadingPeppol, setIsDownloadingPeppol] = useState(false) + // Betalningsbekräftelse (#1693): the paid re-render handed to the customer. + const [isDownloadingConfirmation, setIsDownloadingConfirmation] = useState(false) + const [showConfirmationSendDialog, setShowConfirmationSendDialog] = useState(false) const [isPreparingPeppol, setIsPreparingPeppol] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) @@ -696,6 +702,86 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st ) } + /** + * Download the betalningsbekräftelse (#1693). Always a fresh render with the + * BETALD stamp, never the archived original: the file is named and toasted + * as a payment confirmation so it is not mistaken for the invoice sent. + */ + async function downloadPaymentConfirmation() { + if (!invoice) return + const source = paymentConfirmationPdfSource(invoice.id) + setIsDownloadingConfirmation(true) + + try { + const response = await fetch(source.url) + if (!response.ok) { + const body = await response.json().catch(() => null) as { + error?: { code?: string; message?: string; message_en?: string } + } | null + throw body?.error ?? new Error(t('pdf_generate_failed')) + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = contentDispositionFilename(response.headers.get('Content-Disposition')) + ?? `Betalningsbekraftelse-${invoice.invoice_number ?? invoice.id.slice(0, 8)}.pdf` + document.body.appendChild(anchor) + anchor.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(anchor) + + toast({ + title: t('payment_confirmation_downloaded_title'), + description: t(RERENDER_CAVEAT_KEYS.payment_confirmation), + }) + } catch (error) { + toast({ + title: t('pdf_download_failed_title'), + description: getUserErrorMessage(error, { + context: 'invoice', + locale: locale.startsWith('sv') ? 'sv' : 'en', + }), + variant: 'destructive', + }) + } finally { + setIsDownloadingConfirmation(false) + } + } + + /** Email the betalningsbekräftelse to the customer; confirmed up front. */ + async function sendPaymentConfirmation() { + if (!invoice) return + try { + const response = await fetch(`/api/invoices/${invoice.id}/send-payment-confirmation`, { + method: 'POST', + }) + const body = await response.json().catch(() => null) as { + error?: { code?: string; message?: string; message_en?: string } + } | null + if (!response.ok) { + throw body?.error ?? new Error(t('payment_confirmation_send_failed_description')) + } + + toast({ + title: t('payment_confirmation_sent_title'), + description: t('payment_confirmation_sent_description', { + email: invoice.customer?.email ?? '', + }), + }) + } catch (error) { + toast({ + title: t('payment_confirmation_send_failed_title'), + description: getUserErrorMessage(error, { + context: 'invoice', + locale: locale.startsWith('sv') ? 'sv' : 'en', + }), + variant: 'destructive', + }) + } + } + async function downloadPeppolXml() { if (!invoice) return setIsDownloadingPeppol(true) @@ -997,6 +1083,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const isProforma = docType === 'proforma' const isDeliveryNote = docType === 'delivery_note' const isRealInvoice = docType === 'invoice' + // #1693: only a fully paid faktura has a betalningsbekräftelse to offer. + const canSendPaymentConfirmation = isPaymentConfirmationEligible(invoice) const isCreditNote = !!invoice.credited_invoice_id const booksOnIssue = isCreditNote ? !!originalInvoice && creditNoteNeedsJournalEntry(accountingMethod, originalInvoice) @@ -1857,6 +1945,49 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st )} + + {/* Betalningsbekräftelse (#1693): a fresh BETALD render the + customer can be handed. Lives here rather than in the + header row: it belongs to the payment, not the invoice, + and is a different document from the archived original. */} + {canSendPaymentConfirmation && ( +
+

+ {t('payment_confirmation_hint')} +

+
+ + +
+
+ )} )} @@ -2232,6 +2363,18 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {/* Confirm-before-posting (convention 10): booking writes an immutable verifikat, so the outcome is described before the POST, not narrated in a toast afterwards. */} + + ({ })) import { GET } from '../route' +import { InvoicePDF } from '@/lib/invoices/pdf-template' describe('GET /api/invoices/[id]/pdf', () => { const user = { id: 'user-1', email: 'owner@example.test' } @@ -157,6 +158,111 @@ describe('GET /api/invoices/[id]/pdf', () => { expect(renderToBufferMock).not.toHaveBeenCalled() }) + // #1693: the betalningsbekräftelse variant. Same render, refused unless the + // faktura is fully paid, named as a payment confirmation, archive untouched. + describe('?variant=paid', () => { + const paidInvoice = { + ...invoice, + status: 'paid', + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-08-17T12:00:00+00:00', + } + + 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', { searchParams: { variant: 'paid' } }), + 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', { searchParams: { variant: 'paid' } }), + createMockRouteParams({ id: 'missing' }), + ) + + expect(response.status).toBe(404) + }) + + it.each(['sent', 'partially_paid', 'overdue', 'draft'])( + 'returns 409 without rendering when the invoice is %s', + async (status) => { + enqueue({ data: { ...invoice, status }, error: null }) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }), + createMockRouteParams({ id: 'invoice-1' }), + ) + const body = await response.json() + + expect(response.status).toBe(409) + expect(body.error.code).toBe('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(renderToBufferMock).not.toHaveBeenCalled() + }, + ) + + it('refuses a paid credit note', async () => { + enqueue({ data: { ...paidInvoice, credited_invoice_id: 'orig-1' }, error: null }) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }), + createMockRouteParams({ id: 'invoice-1' }), + ) + + expect(response.status).toBe(409) + }) + + it('renders the paid invoice and names the file as a betalningsbekräftelse', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: company, error: null }) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'paid' } }), + createMockRouteParams({ id: 'invoice-1' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(contentDispositionFilename(response.headers.get('Content-Disposition'))) + .toBe('Betalningsbekraftelse-2621.pdf') + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + // The template gets the row as stored (status paid), so it is the one + // that stamps BETALD; the route does not fake a status. + expect(InvoicePDF).toHaveBeenCalledWith( + expect.objectContaining({ invoice: expect.objectContaining({ status: 'paid' }) }), + ) + // Never a document-archive read: the paid copy is always a fresh render. + expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_deliveries') + expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments') + }) + + it('keeps the invoice filename for an unknown variant value', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: company, error: null }) + + const response = await GET( + createMockRequest('/api/invoices/invoice-1/pdf', { searchParams: { variant: 'evil' } }), + 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') + }) + }) + it('marks PDF generation errors as private and non-cacheable', async () => { enqueue({ data: invoice, error: null }) enqueue({ data: company, error: null }) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index ce84a63c..0c48004f 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -3,7 +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 { invoicePdfFilename, paymentConfirmationPdfFilename } from '@/lib/invoices/pdf-filename' +import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation' 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' @@ -26,6 +27,17 @@ function resolveDisposition(request: Request): 'inline' | 'attachment' { return requested === 'inline' ? 'inline' : 'attachment' } +/** + * `?variant=paid` asks for the betalningsbekräftelse (#1693): the same + * re-render, but refused unless the faktura is fully paid and named as a + * payment confirmation rather than as the invoice. It is always a fresh + * render; the archived original in the delivery history is never involved. + */ +function resolveVariant(request: Request): 'invoice' | 'paid' { + const requested = new URL(request.url).searchParams.get('variant') + return requested === 'paid' ? 'paid' : 'invoice' +} + function privateNoStore(response: NextResponse): NextResponse { response.headers.set('Cache-Control', 'private, no-store') return response @@ -58,6 +70,14 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( ) } + const variant = resolveVariant(request) + if (variant === 'paid' && !isPaymentConfirmationEligible(invoice as Invoice)) { + return privateNoStore(errorResponseFromCode('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID', log, { + requestId, + details: { currentStatus: (invoice as Invoice).status }, + })) + } + // Fetch company settings const { data: company, error: companyError } = await supabase .from('company_settings') @@ -124,15 +144,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // Return PDF as response const isCreditNote = !!invoice.credited_invoice_id - 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, - }) + const filename = variant === 'paid' + ? paymentConfirmationPdfFilename(invoice.invoice_number) + : 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, diff --git a/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts b/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts new file mode 100644 index 00000000..2c75be94 --- /dev/null +++ b/app/api/invoices/[id]/send-payment-confirmation/__tests__/route.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, + makeCompanySettings, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +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 }), +})) + +const mockRenderToBuffer = vi.fn() +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), + Document: vi.fn(), + Page: vi.fn(), + Text: vi.fn(), + View: vi.fn(), + StyleSheet: { create: (s: unknown) => s }, +})) + +vi.mock('@/lib/invoices/pdf-template', () => ({ + InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), + brandingFromCompanySettings: vi.fn().mockReturnValue({}), + SHOW_SWISH_ON_INVOICE: false, +})) +import { InvoicePDF } from '@/lib/invoices/pdf-template' + +const mockSendEmail = vi.fn() +const mockIsConfigured = vi.fn() +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ + sendEmail: (...args: unknown[]) => mockSendEmail(...args), + isConfigured: () => mockIsConfigured(), + }), +})) + +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn().mockResolvedValue(null), + isSandboxCompany: vi.fn().mockResolvedValue(false), + sandboxBlockedResponse: vi.fn(), +})) + +vi.mock('@/lib/entitlements/has-capability', () => ({ + requireCapability: vi.fn().mockResolvedValue(null), +})) + +import { POST } from '../route' + +describe('POST /api/invoices/[id]/send-payment-confirmation', () => { + const mockUser = { id: 'user-1', email: 'owner@test.se' } + const customer = makeCustomer({ id: 'cust-1', name: 'Kund AB', email: 'kund@test.se', language: 'sv' }) + const company = makeCompanySettings({ + company_name: 'Acme AB', + email: 'faktura@acme.se', + bankgiro: '123-4567', + }) + const paidInvoice = makeInvoice({ + id: 'inv-1', + invoice_number: '2026-0042', + status: 'paid', + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-08-17T12:00:00+00:00', + customer, + items: [ + { + id: 'item-1', + invoice_id: 'inv-1', + sort_order: 0, + description: 'Consulting', + quantity: 10, + unit: 'tim', + unit_price: 1000, + line_total: 10000, + vat_rate: 25, + vat_amount: 2500, + created_at: '2026-06-15T14:30:00Z', + }, + ], + }) + + function post(id = 'inv-1') { + return POST( + createMockRequest(`/api/invoices/${id}/send-payment-confirmation`, { method: 'POST' }), + createMockRouteParams({ id }), + ) + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockIsConfigured.mockReturnValue(true) + mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-paid-pdf')) + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1', provider: 'resend' }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const { status } = await parseJsonResponse(await post()) + + expect(status).toBe(401) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 503 when the email service is not configured', async () => { + mockIsConfigured.mockReturnValue(false) + + const { status } = await parseJsonResponse(await post()) + + expect(status).toBe(503) + }) + + it('returns 404 when the invoice does not exist', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const { status, body } = await parseJsonResponse(await post('missing')) + + expect(status).toBe(404) + expect(body.error.code).toBe('INVOICE_PAID_NOT_FOUND') + }) + + it.each(['sent', 'partially_paid', 'overdue', 'draft'])( + 'returns 409 without rendering or mailing when the invoice is %s', + async (status) => { + enqueue({ data: { ...paidInvoice, status }, error: null }) + + const response = await parseJsonResponse(await post()) + + expect(response.status).toBe(409) + expect(response.body.error.code).toBe('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID') + expect(mockRenderToBuffer).not.toHaveBeenCalled() + expect(mockSendEmail).not.toHaveBeenCalled() + }, + ) + + it('returns 400 when the customer has no email', async () => { + enqueue({ data: { ...paidInvoice, customer: { ...customer, email: null } }, error: null }) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(400) + expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL') + expect(mockSendEmail).not.toHaveBeenCalled() + }) + + it('returns 404 when company settings are missing', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: null, error: { message: 'missing' } }) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(404) + expect(body.error.code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING') + }) + + it('emails the paid re-render as a betalningsbekräftelse without touching the invoice', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: company, error: null }) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.message).toContain('kund@test.se') + + // The row is rendered as stored (status paid): the template stamps BETALD. + expect(InvoicePDF).toHaveBeenCalledWith( + expect.objectContaining({ invoice: expect.objectContaining({ status: 'paid' }) }), + ) + + expect(mockSendEmail).toHaveBeenCalledTimes(1) + const sent = mockSendEmail.mock.calls[0][0] as { + to: string[] + subject: string + html: string + text: string + replyTo?: string + fromName?: string + attachments: Array<{ filename: string; content: Buffer; contentType?: string }> + } + expect(sent.to).toEqual(['kund@test.se']) + expect(sent.subject).toBe('Betalningsbekräftelse för faktura 2026-0042 från Acme AB') + expect(sent.html).toContain('betald i sin helhet') + expect(sent.text).toContain('Betalt belopp:') + expect(sent.replyTo).toBe('faktura@acme.se') + expect(sent.fromName).toBe('Acme AB') + expect(sent.attachments).toEqual([ + { + filename: 'Betalningsbekraftelse-2026-0042.pdf', + content: Buffer.from('fake-paid-pdf'), + contentType: 'application/pdf', + }, + ]) + + // No status flip, no delivery-history row, no journal write: the + // confirmation is a resend of a document, not an issuance. + expect(mockSupabase.from).not.toHaveBeenCalledWith('invoice_deliveries') + expect(mockSupabase.from).not.toHaveBeenCalledWith('journal_entries') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('returns 502 when the provider refuses the email', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: company, error: null }) + mockSendEmail.mockResolvedValue({ success: false, error: 'provider down' }) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(502) + expect(body.error.code).toBe('INVOICE_SEND_PROVIDER_FAILED') + }) + + it('returns 500 when the PDF cannot be rendered and sends nothing', async () => { + enqueue({ data: paidInvoice, error: null }) + enqueue({ data: company, error: null }) + mockRenderToBuffer.mockRejectedValueOnce(new Error('render failed')) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(500) + expect(body.error.code).toBe('INVOICE_PDF_RENDER_FAILED') + expect(mockSendEmail).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/invoices/[id]/send-payment-confirmation/route.ts b/app/api/invoices/[id]/send-payment-confirmation/route.ts new file mode 100644 index 00000000..e4745167 --- /dev/null +++ b/app/api/invoices/[id]/send-payment-confirmation/route.ts @@ -0,0 +1,231 @@ +import { NextResponse } from 'next/server' +import { renderToBuffer } from '@react-pdf/renderer' +import { ensureInitialized } from '@/lib/init' +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 { getEmailService } from '@/lib/email/service' +import { + generatePaymentConfirmationEmailHtml, + generatePaymentConfirmationEmailSubject, + generatePaymentConfirmationEmailText, +} from '@/lib/email/invoice-templates' +import { paymentConfirmationPdfFilename } from '@/lib/invoices/pdf-filename' +import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation' +import { + EMAIL_PATTERN, + exceedsInvoiceEmailRecipientLimit, + invoiceEmailRecipientCount, + resolveInvoiceEmailRecipients, +} from '@/lib/invoices/email-recipients' +import { + hasRequiredInvoicePaymentAccount, + invoiceRequiresPaymentAccount, +} from '@/lib/invoices/payment-accounts' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { guardSandbox } from '@/lib/sandbox/guard' +import { requireCapability } from '@/lib/entitlements/has-capability' +import { CAPABILITY } from '@/lib/entitlements/keys' +import type { CompanySettings, Customer, Invoice, InvoiceItem } from '@/types' + +ensureInitialized() + +/** + * POST /api/invoices/[id]/send-payment-confirmation (#1693) + * + * Emails the customer a betalningsbekräftelse: the paid re-render of the + * faktura (BETALD stamp, "Betalt / Att betala: 0") attached to a short + * confirmation mail. Same email service, recipients and branding as the + * invoice send, but deliberately none of its side effects: the invoice + * status, sent_at, journal entries and the delivery history are untouched. + * The delivery history records what was sent as the invoice, and a later + * confirmation must never be mistaken for that file, so this route logs the + * send and writes nothing to the database. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.send_payment_confirmation', + async (_request, { user, supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + const opLog = log.child({ invoiceId: id }) + + // The sandbox must never deliver a real email to a real customer. + const blocked = await guardSandbox(supabase, companyId) + if (blocked) return blocked + + const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.email_send) + if (capBlocked) return capBlocked + + const emailService = getEmailService() + if (!emailService.isConfigured()) { + return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId }) + } + + const { data: invoice, error: invoiceError } = await supabase + .from('invoices') + .select(` + *, + customer:customers(*), + items:invoice_items(*) + `) + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (invoiceError || !invoice) { + return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId }) + } + + if (!isPaymentConfirmationEligible(invoice as Invoice)) { + return errorResponseFromCode('INVOICE_PAYMENT_CONFIRMATION_NOT_PAID', opLog, { + requestId, + details: { currentStatus: (invoice as Invoice).status }, + }) + } + + const customer = invoice.customer as Customer + if (!customer.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) { + return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { + requestId, + details: { customerId: customer.id }, + }) + } + + const { data: company, error: companyError } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single() + + if (companyError || !company) { + return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId }) + } + + // The paid copy still prints the payment block, so it needs the same + // account data the invoice render does. + const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice) + if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) { + return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', opLog, { + requestId, + details: { currency: (invoice as Invoice).currency }, + }) + } + + // Same fixed routing as the invoice send (company/customer CC and BCC, + // the company's own copy). No per-request recipients: this is a + // one-click resend of a document the customer already has a right to. + const recipients = resolveInvoiceEmailRecipients({ + to: customer.email, + configuredCc: company.invoice_email_cc_addresses, + configuredBcc: company.invoice_email_bcc_addresses, + customerCc: customer.invoice_email_cc_addresses, + customerBcc: customer.invoice_email_bcc_addresses, + legacyCc: company.email || user.email, + }) + if (recipients.to.length === 0) { + return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { + requestId, + details: { customerId: customer.id }, + }) + } + if (exceedsInvoiceEmailRecipientLimit(recipients)) { + return errorResponseFromCode('INVOICE_SEND_TOO_MANY_RECIPIENTS', opLog, { + requestId, + details: { recipient_count: invoiceEmailRecipientCount(recipients) }, + }) + } + + const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order) + + let pdfBuffer: Buffer + try { + const { branding, company: renderCompany } = await prepareInvoicePdfRender( + company as CompanySettings, + (invoice as Invoice).currency, + { paymentAccountRequired }, + ) + const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, invoice as Invoice) + const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice) + pdfBuffer = await renderToBuffer( + InvoicePDF({ + invoice: invoice as Invoice, + customer, + items, + company: renderCompany, + branding, + swishQrDataUrl, + paymentLinkQrDataUrl, + }), + ) + } catch (err) { + opLog.error('payment confirmation PDF render failed', err as Error) + return errorResponseFromCode('INVOICE_PDF_RENDER_FAILED', opLog, { requestId }) + } + + const emailData = { + invoice: invoice as Invoice, + customer, + company: company as CompanySettings, + } + const filename = paymentConfirmationPdfFilename(invoice.invoice_number) + + const result = await emailService.sendEmail({ + to: recipients.to, + cc: recipients.cc, + bcc: recipients.bcc, + subject: generatePaymentConfirmationEmailSubject(emailData), + html: generatePaymentConfirmationEmailHtml(emailData), + text: generatePaymentConfirmationEmailText(emailData), + replyTo: company.email || undefined, + fromName: company.company_name, + attachments: [ + { + filename, + content: pdfBuffer, + contentType: 'application/pdf', + }, + ], + }) + + if (!result.success) { + opLog.error( + 'email provider failed to send payment confirmation', + new Error(result.error || 'Unknown'), + ) + return errorResponseFromCode('INVOICE_SEND_PROVIDER_FAILED', opLog, { + requestId, + details: { retryable: true }, + }) + } + + // The delivery history has no kind column for confirmations, and writing + // one there as an email delivery would make it the "sent invoice" to the + // archive logic. Logged instead; a typed history entry is a follow-up. + opLog.info('payment confirmation sent', { + userId: user.id, + invoiceNumber: invoice.invoice_number, + messageId: result.messageId, + recipientCounts: { + to: recipients.to.length, + cc: recipients.cc.length, + }, + }) + + return NextResponse.json( + { + success: true, + message: `Betalningsbekräftelsen har skickats till ${customer.email}`, + messageId: result.messageId, + recipient_counts: { + to: recipients.to.length, + cc: recipients.cc.length, + }, + }, + { headers: { 'Cache-Control': 'private, no-store' } }, + ) + }, + { requireWrite: true }, +) diff --git a/lib/email/__tests__/invoice-templates.test.ts b/lib/email/__tests__/invoice-templates.test.ts index ce328c7e..f0d070d5 100644 --- a/lib/email/__tests__/invoice-templates.test.ts +++ b/lib/email/__tests__/invoice-templates.test.ts @@ -3,6 +3,9 @@ import { generateInvoiceEmailHtml, generateInvoiceEmailText, generateInvoiceEmailSubject, + generatePaymentConfirmationEmailHtml, + generatePaymentConfirmationEmailSubject, + generatePaymentConfirmationEmailText, } from '../invoice-templates' import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers' @@ -487,3 +490,88 @@ describe('invoice email templates', () => { }) }) }) + +// #1693: the betalningsbekräftelse mail. Customer-language driven like the +// invoice mail, but it never lists payment details (nothing is due) and never +// applies the company's custom invoice texts. +describe('payment confirmation email templates', () => { + const paidInvoice = makeInvoice({ + invoice_number: '1042', + invoice_date: '2026-05-22', + currency: 'SEK', + total: 12500, + status: 'paid', + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-06-10T12:00:00+00:00', + }) + const customText = makeCompanySettings({ + company_name: 'Acme AB', + invoice_email_texts: { sv: { subject: 'Egen rubrik {fakturanummer}', body: 'Egen text' } }, + }) + + describe('Swedish customer', () => { + const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' }) + const data = { invoice: paidInvoice, customer, company } + + it('subject names the invoice and the sender', () => { + expect(generatePaymentConfirmationEmailSubject(data)).toBe( + 'Betalningsbekräftelse för faktura 1042 från Acme AB', + ) + }) + + it('html confirms the payment with date and amount, without payment details', () => { + const html = generatePaymentConfirmationEmailHtml(data) + expect(html).toContain('') + expect(html).toContain('Betalningsbekräftelse från Acme AB') + expect(html).toContain('Hej Erik,') + expect(html).toContain('faktura 1042 är betald i sin helhet') + expect(html).toContain('2026-06-10') + expect(html).toContain('12\u00a0500,00 SEK') + expect(html).not.toContain('Betalningsinformation') + expect(html).not.toContain('Att betala:') + }) + + it('text mirrors the html', () => { + const text = generatePaymentConfirmationEmailText(data) + expect(text).toContain('Betalningsbekräftelse från Acme AB') + expect(text).toContain('Betald: 2026-06-10') + expect(text).toContain('Betalt belopp: 12\u00a0500,00 SEK') + expect(text).not.toContain('IBAN') + }) + + it('ignores the company custom invoice texts', () => { + const subject = generatePaymentConfirmationEmailSubject({ ...data, company: customText }) + expect(subject).toBe('Betalningsbekräftelse för faktura 1042 från Acme AB') + expect(generatePaymentConfirmationEmailHtml({ ...data, company: customText })).not.toContain('Egen text') + }) + + it('omits the paid date when paid_at was never recorded', () => { + const text = generatePaymentConfirmationEmailText({ + ...data, + invoice: { ...paidInvoice, paid_at: null }, + }) + expect(text).not.toContain('Betald:') + expect(text).toContain('Betalt belopp:') + }) + }) + + describe('English customer', () => { + const customer = makeCustomer({ name: 'John Smith', email: 'john@example.com', language: 'en' }) + const data = { invoice: paidInvoice, customer, company } + + it('uses English chrome', () => { + expect(generatePaymentConfirmationEmailSubject(data)).toBe( + 'Payment confirmation for invoice 1042 from Acme AB', + ) + const html = generatePaymentConfirmationEmailHtml(data) + expect(html).toContain('') + expect(html).toContain('Hi John,') + expect(html).toContain('invoice 1042 has been paid in full') + expect(html).toContain('12,500.00 SEK') + const text = generatePaymentConfirmationEmailText(data) + expect(text).toContain('Paid on: 2026-06-10') + expect(text).toContain('Amount paid: 12,500.00 SEK') + }) + }) +}) diff --git a/lib/email/invoice-templates.ts b/lib/email/invoice-templates.ts index 998c3cb0..e25705b5 100644 --- a/lib/email/invoice-templates.ts +++ b/lib/email/invoice-templates.ts @@ -39,6 +39,13 @@ const LABELS = { fSkatt: 'Innehar F-skattsedel', documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`, subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`, + // Payment confirmation (#1693) + confirmationSubject: (num: string, sender: string) => `Betalningsbekräftelse för faktura ${num} från ${sender}`, + confirmationHeading: (sender: string) => `Betalningsbekräftelse från ${sender}`, + confirmationBody: (num: string) => `Tack för din betalning! Vi bekräftar att faktura ${num} är betald i sin helhet. Bifogat hittar du en betalningsbekräftelse.`, + confirmationPaidOn: 'Betald:', + confirmationPaidAmount: 'Betalt belopp:', + confirmationQuestions: 'Har du frågor om betalningen? Svara direkt på detta mejl så hjälper vi dig.', }, en: { docInvoice: 'Invoice', @@ -70,6 +77,12 @@ const LABELS = { fSkatt: 'Innehar F-skattsedel', documentSummary: (doc: string) => `${doc} summary:`, subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`, + confirmationSubject: (num: string, sender: string) => `Payment confirmation for invoice ${num} from ${sender}`, + confirmationHeading: (sender: string) => `Payment confirmation from ${sender}`, + confirmationBody: (num: string) => `Thank you for your payment. We confirm that invoice ${num} has been paid in full. A payment confirmation is attached.`, + confirmationPaidOn: 'Paid on:', + confirmationPaidAmount: 'Amount paid:', + confirmationQuestions: 'Questions about the payment? Reply directly to this email and we will help you.', }, } as const @@ -426,3 +439,143 @@ export function generateInvoiceEmailSubject(data: InvoiceEmailData): string { const documentType = getDocumentLabel(invoice, lang) return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company)) } + +// --------------------------------------------------------------------------- +// Payment confirmation (#1693) +// +// Sent on request for a paid faktura, with the BETALD re-render attached. It +// is not an invoice send: no custom invoice texts apply, no payment details +// are listed (nothing is due), and nothing here touches delivery history. +// --------------------------------------------------------------------------- + +function paidAmountForCustomer(invoice: Invoice, company: CompanySettings): number { + // What the customer actually paid; the deduction-aware amount to pay is only + // the fallback for legacy rows marked paid before paid_amount was recorded. + return invoice.paid_amount ?? getAmountToPay(invoice, company).toPay +} + +function paidDateForCustomer(invoice: Invoice): string | null { + return invoice.paid_at ? formatDate(invoice.paid_at) : null +} + +export function generatePaymentConfirmationEmailSubject(data: InvoiceEmailData): string { + const { invoice, customer, company } = data + const L = LABELS[resolveLang(customer)] + return sanitizeSubjectLine( + L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)), + ) +} + +export function generatePaymentConfirmationEmailHtml(data: InvoiceEmailData): string { + const { invoice, customer, company } = data + const lang = resolveLang(customer) + const L = LABELS[lang] + const firstName = customer.name ? customer.name.split(' ')[0] : '' + const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111') + const paidDate = paidDateForCustomer(invoice) + const paidAmount = formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang) + const invoiceNumber = escapeHtml(invoice.invoice_number ?? '') + + return ` + + + + + + ${escapeHtml(L.confirmationSubject(invoice.invoice_number ?? '', getCompanyPrimaryName(company)))} + + +
+
+

+ ${escapeHtml(L.confirmationHeading(getCompanyPrimaryName(company)))} +

+

+ ${L.documentNumber(L.docInvoice)} ${invoiceNumber} +

+
+ +
+

${escapeHtml(L.greeting(firstName))}

+

${escapeHtml(L.confirmationBody(invoice.invoice_number ?? ''))}

+
+ +
+ + + + + + + + + + ${paidDate ? ` + + + + + ` : ''} + + + + + + + +
${L.documentNumber(L.docInvoice)}${invoiceNumber}
${L.documentDate(L.docInvoice)}${formatDate(invoice.invoice_date)}
${L.confirmationPaidOn}${paidDate}
${L.confirmationPaidAmount} + ${paidAmount} +
+
+ +
+

${L.confirmationQuestions}

+

+ ${L.sincerely}
+ ${escapeHtml(getCompanyPrimaryName(company))} +

+ ${company.org_number ? ` +

+ ${L.orgNo} ${escapeHtml(company.org_number)} + ${company.vat_number ? ` | ${L.vat} ${escapeHtml(company.vat_number)}` : ''} + ${company.f_skatt ? ` | ${L.fSkatt}` : ''} +

+ ` : ''} +
+
+ + +` +} + +export function generatePaymentConfirmationEmailText(data: InvoiceEmailData): string { + const { invoice, customer, company } = data + const lang = resolveLang(customer) + const L = LABELS[lang] + const firstName = customer.name ? customer.name.split(' ')[0] : '' + const paidDate = paidDateForCustomer(invoice) + const number = invoice.invoice_number ?? '' + + let text = `${L.confirmationHeading(getCompanyPrimaryName(company))}\n` + text += `${L.documentNumber(L.docInvoice)} ${number}\n\n` + text += `${L.greeting(firstName)}\n\n` + text += `${L.confirmationBody(number)}\n\n` + text += `---\n` + text += `${L.documentNumber(L.docInvoice)} ${number}\n` + text += `${L.documentDate(L.docInvoice)} ${formatDate(invoice.invoice_date)}\n` + if (paidDate) text += `${L.confirmationPaidOn} ${paidDate}\n` + text += `${L.confirmationPaidAmount} ${formatCurrencyForCustomer(paidAmountForCustomer(invoice, company), invoice.currency, lang)}\n` + text += `---\n\n` + text += `${L.confirmationQuestions}\n\n` + text += `${L.sincerely}\n` + text += `${getCompanyDisplayName(company)}\n` + + if (company.org_number) { + text += `\n${L.orgNo} ${company.org_number}` + if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}` + if (company.f_skatt) text += ` | ${L.fSkatt}` + text += `\n` + } + + return text +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 6e7d1b63..99cdff9b 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1093,6 +1093,16 @@ const INVOICE: Record = { message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.', message_en: 'Invoice is not in a payable status.', }, + INVOICE_PAYMENT_CONFIRMATION_NOT_PAID: { + httpStatus: 409, + message_sv: + 'En betalningsbekräftelse kan bara skapas för en faktura som är fullt betald.', + message_en: 'A payment confirmation can only be produced for a fully paid invoice.', + remediation: { + description: + 'Register the payment first (POST /api/invoices/{id}/mark-paid) so the invoice reaches status paid; credit notes and proformas never qualify.', + }, + }, INVOICE_PAID_LINES_UNBALANCED: { httpStatus: 400, message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).', diff --git a/lib/invoices/__tests__/invoice-pdf-source.test.ts b/lib/invoices/__tests__/invoice-pdf-source.test.ts index 2d4b9cb3..87696c39 100644 --- a/lib/invoices/__tests__/invoice-pdf-source.test.ts +++ b/lib/invoices/__tests__/invoice-pdf-source.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { invoiceDocumentCaveat, + invoicePaymentConfirmationUrl, invoiceRerenderUrl, + paymentConfirmationPdfSource, resolveInvoicePdfSource, type InvoicePdfDelivery, } from '@/lib/invoices/invoice-pdf-source' @@ -231,3 +233,37 @@ describe('invoiceRerenderUrl', () => { ) }) }) + +// #1693: the betalningsbekräftelse is always a re-render and always says so, +// however the delivery history looks. An archived delivery is the invoice as +// sent (unpaid); the paid copy is a different document and never replaces it. +describe('paymentConfirmationPdfSource', () => { + it('is a re-render of the paid variant with its own caveat', () => { + const source = paymentConfirmationPdfSource(INVOICE_ID) + + expect(source).toEqual({ + kind: 'rerender', + url: `/api/invoices/${INVOICE_ID}/pdf?variant=paid`, + reason: 'payment_confirmation', + }) + expect(invoiceDocumentCaveat(source)).toBe('payment_confirmation') + }) + + it('never resolves to the archived delivery, even when one exists', () => { + const archived = resolveInvoicePdfSource({ + invoiceId: INVOICE_ID, + invoiceStatus: 'paid', + deliveriesLoaded: true, + deliveries: [emailDelivery()], + }) + expect(archived.kind).toBe('archived') + + const confirmation = paymentConfirmationPdfSource(INVOICE_ID) + expect(confirmation.kind).toBe('rerender') + expect(confirmation.url).not.toContain('/api/documents/') + }) + + it('encodes the invoice id in the confirmation url', () => { + expect(invoicePaymentConfirmationUrl('a/b')).toBe('/api/invoices/a%2Fb/pdf?variant=paid') + }) +}) diff --git a/lib/invoices/__tests__/payment-confirmation.test.ts b/lib/invoices/__tests__/payment-confirmation.test.ts new file mode 100644 index 00000000..91046f33 --- /dev/null +++ b/lib/invoices/__tests__/payment-confirmation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { isPaymentConfirmationEligible } from '@/lib/invoices/payment-confirmation' +import { makeInvoice } from '@/tests/helpers' + +describe('isPaymentConfirmationEligible', () => { + it('accepts a fully paid faktura', () => { + expect(isPaymentConfirmationEligible(makeInvoice({ status: 'paid' }))).toBe(true) + }) + + it.each(['draft', 'sent', 'overdue', 'partially_paid', 'cancelled', 'credited'] as const)( + 'refuses status %s', + (status) => { + expect(isPaymentConfirmationEligible(makeInvoice({ status }))).toBe(false) + }, + ) + + it('refuses credit notes even when marked paid', () => { + expect( + isPaymentConfirmationEligible(makeInvoice({ status: 'paid', credited_invoice_id: 'orig' })), + ).toBe(false) + }) + + it.each(['proforma', 'delivery_note'] as const)('refuses document type %s', (document_type) => { + expect(isPaymentConfirmationEligible(makeInvoice({ status: 'paid', document_type }))).toBe(false) + }) + + it('treats a missing document_type as a regular invoice', () => { + expect( + isPaymentConfirmationEligible({ status: 'paid', credited_invoice_id: null, document_type: null }), + ).toBe(true) + }) +}) diff --git a/lib/invoices/__tests__/pdf-filename.test.ts b/lib/invoices/__tests__/pdf-filename.test.ts index 0a42b147..be1acab6 100644 --- a/lib/invoices/__tests__/pdf-filename.test.ts +++ b/lib/invoices/__tests__/pdf-filename.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { invoicePdfFilename } from '../pdf-filename' +import { invoicePdfFilename, paymentConfirmationPdfFilename } from '../pdf-filename' describe('invoicePdfFilename', () => { it('includes company, customer, document type, number, and invoice date', () => { @@ -66,3 +66,19 @@ describe('invoicePdfFilename', () => { expect(filename).toMatch(/Faktura nr 2621 20260721\.pdf$/) }) }) + +// #1693: the paid copy is named as a betalningsbekräftelse, never as the +// invoice, so the file cannot be mistaken for the one that was sent. +describe('paymentConfirmationPdfFilename', () => { + it('names the file after the invoice number', () => { + expect(paymentConfirmationPdfFilename('2621')).toBe('Betalningsbekraftelse-2621.pdf') + }) + + it('sanitizes unsafe characters and spaces in the number', () => { + expect(paymentConfirmationPdfFilename('F 2026/0042')).toBe('Betalningsbekraftelse-F-2026-0042.pdf') + }) + + it('falls back when the number is missing', () => { + expect(paymentConfirmationPdfFilename(null)).toBe('Betalningsbekraftelse-okand.pdf') + }) +}) diff --git a/lib/invoices/__tests__/pdf-template-paid.test.ts b/lib/invoices/__tests__/pdf-template-paid.test.ts new file mode 100644 index 00000000..1c43439f --- /dev/null +++ b/lib/invoices/__tests__/pdf-template-paid.test.ts @@ -0,0 +1,186 @@ +/** + * Betald-stämpel (#1693). A paid faktura re-renders with a BETALD banner and + * "Betalt / Att betala: 0" totals; a partly paid one gets the two rows without + * the banner; everything else renders exactly as before. Credit notes and + * proformas never carry a payment state. + */ +import { describe, expect, it } from 'vitest' +import type { ReactElement, ReactNode } from 'react' +import { InvoicePDF, resolvePdfPaidState, type InvoicePdfInvoice } from '@/lib/invoices/pdf-template' +import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers' +import type { InvoiceItem } from '@/types' + +/** Every string leaf in the element tree, in document order. */ +function textLeaves(node: ReactNode, out: string[] = []): string[] { + if (node === null || node === undefined || typeof node === 'boolean') return out + if (typeof node === 'string' || typeof node === 'number') { + out.push(String(node)) + return out + } + if (Array.isArray(node)) { + for (const child of node) textLeaves(child, out) + return out + } + const element = node as ReactElement<{ children?: ReactNode }> + if (element.props) textLeaves(element.props.children, out) + return out +} + +const items: InvoiceItem[] = [ + { + id: 'item-1', + invoice_id: 'invoice-1', + sort_order: 0, + line_type: 'product', + description: 'Konsulttimmar', + quantity: 10, + unit: 'tim', + unit_price: 1000, + line_total: 10000, + vat_rate: 25, + vat_amount: 2500, + created_at: '2026-01-15T00:00:00Z', + }, +] + +function renderText(invoice: InvoicePdfInvoice, language?: 'sv' | 'en'): string { + const tree = InvoicePDF({ + invoice, + customer: makeCustomer({ language: language ?? 'sv' }), + items, + company: makeCompanySettings(), + }) + return textLeaves(tree).join('\n') +} + +const paidInvoice = (overrides: Partial = {}): InvoicePdfInvoice => + makeInvoice({ + id: 'invoice-1', + status: 'paid', + invoice_number: '2026-0042', + total: 12500, + paid_amount: 12500, + remaining_amount: 0, + paid_at: '2026-08-17T12:00:00+00:00', + ...overrides, + }) + +describe('invoice PDF paid state (sv)', () => { + it('stamps a paid invoice BETALD with date and amount, and states Att betala 0', () => { + const text = renderText(paidInvoice()) + + expect(text).toContain('BETALD') + expect(text).toContain('Betald 2026-08-17 · 12\u00a0500,00 SEK') + expect(text).toContain('Betalt:') + expect(text).toContain('Att betala:') + // Betalt comes first, then Att betala 0. + const betalt = text.indexOf('Betalt:') + const attBetala = text.indexOf('Att betala:', betalt) + expect(betalt).toBeGreaterThan(-1) + expect(attBetala).toBeGreaterThan(betalt) + expect(text.slice(attBetala)).toContain('0,00 SEK') + }) + + it('shows Betalt and the remaining amount without a banner when partially paid', () => { + const text = renderText( + paidInvoice({ status: 'partially_paid', paid_amount: 5000, remaining_amount: 7500 }), + ) + + expect(text).not.toContain('BETALD') + expect(text).toContain('Betalt:') + expect(text).toContain('5\u00a0000,00 SEK') + expect(text).toContain('Att betala:') + expect(text).toContain('7\u00a0500,00 SEK') + }) + + it('renders an unpaid invoice exactly as before: no banner, no Betalt row', () => { + const text = renderText(paidInvoice({ status: 'sent', paid_amount: null, remaining_amount: 12500, paid_at: null })) + + expect(text).not.toContain('BETALD') + expect(text).not.toContain('Betalt:') + expect(text).toContain('Att betala:') + expect(text).toContain('12\u00a0500,00 SEK') + }) + + it('falls back to the amount to pay when paid_amount was never recorded', () => { + const text = renderText(paidInvoice({ paid_amount: null, paid_at: null })) + + expect(text).toContain('BETALD') + expect(text).toContain('Betald · 12\u00a0500,00 SEK') + expect(text).not.toContain('Betald null') + }) + + it('leaves credit notes and proformas alone even when their status is paid', () => { + const credit = renderText(paidInvoice({ credited_invoice_id: 'orig-1' })) + expect(credit).not.toContain('BETALD') + expect(credit).not.toContain('Betalt:') + expect(credit).toContain('Att kreditera:') + + const proforma = renderText(paidInvoice({ document_type: 'proforma' })) + expect(proforma).not.toContain('BETALD') + expect(proforma).not.toContain('Betalt:') + }) +}) + +describe('invoice PDF paid state (en)', () => { + it('stamps PAID with date and amount and states Total due 0', () => { + const text = renderText(paidInvoice(), 'en') + + expect(text).toContain('PAID') + expect(text).toContain('Paid 2026-08-17 · 12,500.00 SEK') + expect(text).toContain('Paid:') + const paid = text.indexOf('Paid:') + const due = text.indexOf('Total due:', paid) + expect(due).toBeGreaterThan(paid) + expect(text.slice(due)).toContain('0.00 SEK') + }) + + it('shows Paid and the remainder without a banner when partially paid', () => { + const text = renderText( + paidInvoice({ status: 'partially_paid', paid_amount: 5000, remaining_amount: 7500 }), + 'en', + ) + + expect(text).not.toContain('PAID') + expect(text).toContain('Paid:') + expect(text).toContain('5,000.00 SEK') + expect(text).toContain('Total due:') + expect(text).toContain('7,500.00 SEK') + }) + + it('renders an unpaid invoice without any paid wording', () => { + const text = renderText(paidInvoice({ status: 'overdue', paid_amount: null, paid_at: null }), 'en') + + expect(text).not.toContain('PAID') + expect(text).not.toContain('Paid:') + expect(text).toContain('Total due:') + }) +}) + +describe('resolvePdfPaidState', () => { + it('keeps the customer-paid amount rather than recomputing deductions', () => { + const state = resolvePdfPaidState( + makeInvoice({ status: 'paid', paid_amount: 7000, remaining_amount: 0, deduction_total: 3000 }), + 'invoice', + false, + 7000, + ) + expect(state).toEqual({ kind: 'paid', paidAmount: 7000, remainingAmount: 0, paidDate: null }) + }) + + it('derives the remainder for a partly paid row that lacks remaining_amount', () => { + const state = resolvePdfPaidState( + { ...makeInvoice({ status: 'partially_paid', paid_amount: 4000.5 }), remaining_amount: undefined as unknown as number }, + 'invoice', + false, + 12500, + ) + expect(state).toMatchObject({ kind: 'partially_paid', paidAmount: 4000.5, remainingAmount: 8499.5 }) + }) + + it('returns null for other statuses, credit notes and non-invoice documents', () => { + expect(resolvePdfPaidState(makeInvoice({ status: 'sent' }), 'invoice', false, 1)).toBeNull() + expect(resolvePdfPaidState(makeInvoice({ status: 'paid' }), 'invoice', true, 1)).toBeNull() + expect(resolvePdfPaidState(makeInvoice({ status: 'paid' }), 'delivery_note', false, 1)).toBeNull() + }) +}) diff --git a/lib/invoices/invoice-pdf-source.ts b/lib/invoices/invoice-pdf-source.ts index d6e78f9e..4770db8a 100644 --- a/lib/invoices/invoice-pdf-source.ts +++ b/lib/invoices/invoice-pdf-source.ts @@ -68,6 +68,12 @@ export type InvoicePdfRerenderReason = | 'no_archived_copy' /** An archived copy may exist but could not be retrieved; the user chose this. */ | 'archive_unreachable' + /** + * The betalningsbekräftelse (#1693): a fresh render of a paid invoice with + * the BETALD stamp. It is its own document, produced on request, and is never + * the file the customer was originally sent, however the archive looks. + */ + | 'payment_confirmation' export type InvoicePdfSource = | { @@ -96,6 +102,31 @@ export function invoiceRerenderUrl(invoiceId: string, options?: { inline?: boole return options?.inline ? `${base}?disposition=inline` : base } +/** + * The betalningsbekräftelse endpoint: the same re-render route in its paid + * variant. The route refuses anything but a paid faktura and names the file + * accordingly; the archived original is not involved at any point. + */ +export function invoicePaymentConfirmationUrl(invoiceId: string): string { + return `${invoiceRerenderUrl(invoiceId)}?variant=paid` +} + +/** + * The paid copy is always a re-render and is always labelled as one. There is + * no archived variant to look for: even when the delivery history holds the + * sent invoice, that file shows the invoice as it was before payment and is + * a different document from the betalningsbekräftelse. + */ +export function paymentConfirmationPdfSource( + invoiceId: string, +): Extract { + return { + kind: 'rerender', + url: invoicePaymentConfirmationUrl(invoiceId), + reason: 'payment_confirmation', + } +} + /** * Decide which file the download button may fetch. * diff --git a/lib/invoices/payment-confirmation.ts b/lib/invoices/payment-confirmation.ts new file mode 100644 index 00000000..3d8189e7 --- /dev/null +++ b/lib/invoices/payment-confirmation.ts @@ -0,0 +1,21 @@ +import type { Invoice, InvoiceDocumentType } from '@/types' + +/** + * Which invoices may be handed to the customer as a betalningsbekräftelse + * (#1693): a real faktura whose status is `paid`. Credit notes settle against + * their original and proformas are not payment requests, so neither has a + * "betald" state worth confirming. `partially_paid` is excluded on purpose: + * the customer still owes money, and a document stamped BETALD would say + * otherwise. + * + * Shared by the PDF route (`?variant=paid`), the send-confirmation route and + * the detail page, so the three can never disagree on who gets the button. + */ +export function isPaymentConfirmationEligible( + invoice: Pick & { document_type?: InvoiceDocumentType | null }, +): boolean { + if (invoice.credited_invoice_id) return false + const docType = invoice.document_type || 'invoice' + if (docType !== 'invoice') return false + return invoice.status === 'paid' +} diff --git a/lib/invoices/pdf-filename.ts b/lib/invoices/pdf-filename.ts index 4cab1e82..761675a7 100644 --- a/lib/invoices/pdf-filename.ts +++ b/lib/invoices/pdf-filename.ts @@ -83,3 +83,16 @@ export function invoicePdfFilename({ return fitFilename(company, customer, suffix) } + +/** + * Filename for the betalningsbekräftelse (#1693): the paid re-render of a + * settled faktura. Deliberately not the invoice filename shape above, so the + * file can never be mistaken for the invoice that was sent. ASCII on purpose + * (no ä): the name travels as an email attachment to arbitrary mail clients. + * + * Example: `Betalningsbekraftelse-2621.pdf`. + */ +export function paymentConfirmationPdfFilename(invoiceNumber: string | null | undefined): string { + const number = safeFilenamePart(invoiceNumber, 'okand', MAX_NUMBER_PART_LENGTH).replace(/\s+/g, '-') + return `Betalningsbekraftelse-${number}.pdf` +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 6c3fb2b3..b16703d2 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -39,6 +39,9 @@ const LABELS = { draftTitle: 'UTKAST: inte en giltig faktura', draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.', draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.', + paidTitle: 'BETALD', + paidBannerText: (date: string, amount: string) => `Betald ${date} · ${amount}`, + paidBannerNoDate: (amount: string) => `Betald · ${amount}`, // Credit note reference creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`, // Sections @@ -77,6 +80,7 @@ const LABELS = { deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.', toCredit: 'Att kreditera:', toPay: 'Att betala:', + paidRow: 'Betalt:', vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`, totalInSek: 'Totalt i SEK:', // Proforma / exempt @@ -119,6 +123,9 @@ const LABELS = { draftTitle: 'DRAFT: not a valid invoice', draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.', draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.', + paidTitle: 'PAID', + paidBannerText: (date: string, amount: string) => `Paid ${date} · ${amount}`, + paidBannerNoDate: (amount: string) => `Paid · ${amount}`, creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`, invoiceInfoHeading: 'Invoice information', billedToHeading: 'Billed to', @@ -151,6 +158,7 @@ const LABELS = { deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.', toCredit: 'To credit:', toPay: 'Total due:', + paidRow: 'Paid:', vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`, totalInSek: 'Total in SEK:', proformaNotice: 'This is a proforma invoice and is not a request for payment.', @@ -515,6 +523,26 @@ function createStyles(branding?: InvoiceBranding) { color: '#721c24', textAlign: 'center', }, + paidBanner: { + marginBottom: 16, + padding: 10, + backgroundColor: '#d4edda', + borderWidth: 2, + borderColor: '#155724', + borderRadius: 4, + }, + paidBannerTitle: { + fontSize: 14, + fontWeight: 'bold', + color: '#155724', + textAlign: 'center', + marginBottom: 2, + }, + paidBannerText: { + fontSize: 9, + color: '#155724', + textAlign: 'center', + }, footer: { position: 'absolute', bottom: 30, @@ -647,6 +675,44 @@ function formatOrgNumber(orgNumber: string): string { return orgNumber } +/** + * Payment state the PDF prints for a real faktura (#1693): the BETALD stamp + * and the "Betalt / Att betala" rows. Null for every other document or status, + * so unpaid invoices, credit notes and proformas render exactly as before. + * + * `paid_amount` is what the customer actually paid; the deduction-aware amount + * to pay is only the fallback for legacy rows marked paid before paid_amount + * existed. `remaining_amount` is the row's own figure: 0 once fully paid. + */ +export interface PdfPaidState { + kind: 'paid' | 'partially_paid' + paidAmount: number + remainingAmount: number + /** ISO yyyy-MM-dd, or null when paid_at was never recorded. */ + paidDate: string | null +} + +export function resolvePdfPaidState( + invoice: Invoice, + docType: InvoiceDocumentType, + isCreditNote: boolean, + amountToPay: number, +): PdfPaidState | null { + if (isCreditNote || docType !== 'invoice') return null + if (invoice.status !== 'paid' && invoice.status !== 'partially_paid') return null + const paidAmount = invoice.paid_amount ?? (invoice.status === 'paid' ? amountToPay : 0) + const remainingAmount = + invoice.status === 'paid' + ? 0 + : invoice.remaining_amount ?? Math.max(0, Math.round((amountToPay - paidAmount) * 100) / 100) + return { + kind: invoice.status, + paidAmount, + remainingAmount, + paidDate: invoice.paid_at ? formatDate(invoice.paid_at) : null, + } +} + function getDocumentTitle(invoice: Invoice, lang: PdfLang): string { const L = LABELS[lang] if (invoice.credited_invoice_id) return L.titleCreditNote @@ -727,6 +793,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN const isDeliveryNote = docType === 'delivery_note' const isProforma = docType === 'proforma' + // Shared with the invoice email (lib/email/invoice-templates.ts) so the + // mail and the PDF always state the same "Att betala". Computed once here + // because the paid state below needs it too. + const amountToPay = getAmountToPay(invoice, company) + + // Payment state (#1693). Only a real faktura carries it: credit notes are + // settled against their original, proformas are not a payment request. The + // paid amount is what the customer actually paid (paid_amount), not a + // recomputation of the deduction-aware total; the fallback to the amount to + // pay covers legacy rows marked paid before paid_amount was recorded. + const paidState = resolvePdfPaidState(invoice, docType, isCreditNote, amountToPay.toPay) + // Optional branding banner text. Rendered only when the company has set // invoice_header_text: invisible chrome by default, so the byte-equivalence // promise for un-branded callers holds. @@ -758,7 +836,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN : L.cancelledNoNumber} - ) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && ( + ) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? ( {L.draftTitle} @@ -767,6 +845,18 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN : L.draftNoNumber} + ) : paidState?.kind === 'paid' && ( + // BETALD stamp (#1693): the re-rendered copy of a settled faktura + // doubles as the betalningsbekräftelse the customer can be handed. + // partially_paid gets no banner, only the Betalt / Att betala rows. + + {L.paidTitle} + + {paidState.paidDate + ? L.paidBannerText(paidState.paidDate, formatPdfCurrency(paidState.paidAmount, invoice.currency, lang)) + : L.paidBannerNoDate(formatPdfCurrency(paidState.paidAmount, invoice.currency, lang))} + + )} {/* Header */} @@ -988,10 +1078,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN ) )} {(() => { - // Shared with the invoice email (lib/email/invoice-templates.ts) - // so the mail and the PDF always state the same "Att betala". - const { rounding, deductionApplies: showDeduction, toPay: grandTotal } = - getAmountToPay(invoice, company) + const { rounding, deductionApplies: showDeduction, toPay: grandTotal } = amountToPay return ( <> {rounding.applies && ( @@ -1011,10 +1098,28 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )} - - {isCreditNote ? L.toCredit : L.toPay} - {formatPdfCurrency(grandTotal, invoice.currency, lang)} - + {paidState ? ( + // Settled or partly settled faktura: state what was paid, + // then what is still due (0 when fully paid). The bold + // total row is the figure that matters to the reader: + // the paid amount on a betald faktura, the remainder on + // a partly paid one. + <> + + {L.paidRow} + {formatPdfCurrency(paidState.paidAmount, invoice.currency, lang)} + + + {L.toPay} + {formatPdfCurrency(paidState.remainingAmount, invoice.currency, lang)} + + + ) : ( + + {isCreditNote ? L.toCredit : L.toPay} + {formatPdfCurrency(grandTotal, invoice.currency, lang)} + + )} ) })()} diff --git a/messages/en.json b/messages/en.json index 2716860c..ac6fd80e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3911,6 +3911,18 @@ "payment_status_view_voucher": "Voucher {label}", "payment_status_view_voucher_unlinked": "No voucher linked", "payment_status_empty": "No payments recorded yet.", + "payment_confirmation_hint": "A new document with a PAID stamp. The sent invoice is left untouched.", + "payment_confirmation_download": "Download paid copy", + "payment_confirmation_send": "Send payment confirmation", + "payment_confirmation_no_email": "The customer has no email address", + "payment_confirmation_downloaded_title": "Payment confirmation downloaded", + "payment_confirmation_sent_title": "Payment confirmation sent", + "payment_confirmation_sent_description": "The email with the paid copy went to {email}.", + "payment_confirmation_send_failed_title": "Could not send the payment confirmation", + "payment_confirmation_send_failed_description": "The email could not be sent. Try again.", + "payment_confirmation_confirm_title": "Send payment confirmation?", + "payment_confirmation_confirm_description": "An email titled \"Payment confirmation for invoice {number}\" is sent to {email} with the paid copy (PAID stamp, nothing left to pay) attached. The invoice status and delivery history are not affected.", + "payment_confirmation_confirm_action": "Send", "reminders_card_title": "Reminders", "reminders_description": "Automatic reminders are sent when the invoice is {day1}, {day2} and {day3} days overdue", "reminders_disabled": "Automatic reminders are turned off in settings.", @@ -3998,6 +4010,7 @@ "pdf_rerender_reason_sent_outside": "The latest send happened outside Accounted, so there is no archived copy of that particular send. The file was generated just now, from today's data, template and logo.", "pdf_rerender_reason_no_archive": "No archived copy exists for this invoice; it was sent before delivery history was recorded. The file was generated just now, from today's data, template and logo.", "pdf_rerender_reason_archive_unreachable": "The archived copy could not be retrieved. The file was generated just now, from today's data, template and logo, and is not necessarily identical to the one the customer received.", + "pdf_rerender_reason_payment_confirmation": "The payment confirmation is a new document, produced now with a PAID stamp from today's data. It is not the invoice that was sent to the customer; that file stays untouched in the delivery history.", "pdf_archive_issue_title": "The archived copy could not be retrieved", "pdf_archive_issue_history_desc": "The delivery history could not be loaded, so Accounted does not know which PDF the customer actually received. Nothing was fetched. A freshly generated PDF is not the same document: it is built from today's data, template and logo.", "pdf_archive_issue_document_desc": "The archived PDF that was sent to the customer could not be retrieved right now. Nothing was fetched. A freshly generated PDF is not the same document: it is built from today's data, template and logo.", diff --git a/messages/sv.json b/messages/sv.json index ac649465..1365282a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3911,6 +3911,18 @@ "payment_status_view_voucher": "Verifikat {label}", "payment_status_view_voucher_unlinked": "Saknar verifikat", "payment_status_empty": "Inga registrerade betalningar ännu.", + "payment_confirmation_hint": "Nytt dokument med BETALD-stämpel. Den skickade fakturan rörs inte.", + "payment_confirmation_download": "Ladda ner betald kopia", + "payment_confirmation_send": "Skicka betalningsbekräftelse", + "payment_confirmation_no_email": "Kunden saknar e-postadress", + "payment_confirmation_downloaded_title": "Betalningsbekräftelse nedladdad", + "payment_confirmation_sent_title": "Betalningsbekräftelse skickad", + "payment_confirmation_sent_description": "Mejlet med den betalda kopian gick till {email}.", + "payment_confirmation_send_failed_title": "Kunde inte skicka betalningsbekräftelsen", + "payment_confirmation_send_failed_description": "Mejlet kunde inte skickas. Försök igen.", + "payment_confirmation_confirm_title": "Skicka betalningsbekräftelse?", + "payment_confirmation_confirm_description": "Ett mejl med rubriken \"Betalningsbekräftelse för faktura {number}\" skickas till {email} med den betalda kopian (BETALD-stämpel, inget kvar att betala) som bilaga. Fakturans status och utskickshistorik påverkas inte.", + "payment_confirmation_confirm_action": "Skicka", "reminders_card_title": "Påminnelser", "reminders_description": "Automatiska påminnelser skickas när fakturan varit förfallen i {day1}, {day2} och {day3} dagar", "reminders_disabled": "Automatiska påminnelser är avstängda i inställningarna.", @@ -3998,6 +4010,7 @@ "pdf_rerender_reason_sent_outside": "Det senaste utskicket gjordes utanför Accounted, så det finns ingen arkiverad kopia av just det utskicket. Filen skapades nyss, utifrån dagens uppgifter, mall och logotyp.", "pdf_rerender_reason_no_archive": "Ingen arkiverad kopia finns för den här fakturan; den skickades innan utskickshistoriken började sparas. Filen skapades nyss, utifrån dagens uppgifter, mall och logotyp.", "pdf_rerender_reason_archive_unreachable": "Den arkiverade kopian kunde inte hämtas. Filen skapades nyss, utifrån dagens uppgifter, mall och logotyp, och är inte nödvändigtvis identisk med den kunden fick.", + "pdf_rerender_reason_payment_confirmation": "Betalningsbekräftelsen är ett nytt dokument, skapat nu med BETALD-stämpel utifrån dagens uppgifter. Det är inte fakturan som skickades till kunden; den ligger kvar orörd i utskickshistoriken.", "pdf_archive_issue_title": "Den arkiverade kopian kunde inte hämtas", "pdf_archive_issue_history_desc": "Utskickshistoriken kunde inte läsas in, så Accounted vet inte vilken PDF kunden faktiskt fick. Ingenting har hämtats. En nyskapad PDF är inte samma dokument: den bygger på dagens uppgifter, mall och logotyp.", "pdf_archive_issue_document_desc": "Den arkiverade PDF:en som skickades till kunden kunde inte hämtas just nu. Ingenting har hämtats. En nyskapad PDF är inte samma dokument: den bygger på dagens uppgifter, mall och logotyp.",