feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund (#1700)

* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund

Closes #1693. A paid faktura re-renders with a BETALD banner (paid date
and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid
gets the Betalt / Att betala (remaining) rows without a banner. Credit
notes and proformas are unchanged. Labels in sv and en.

The paid copy is its own document, a betalningsbekräftelse, never the
archived original: GET /api/invoices/[id]/pdf?variant=paid refuses
anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID),
names the file Betalningsbekraftelse-<nr>.pdf and never reads or
replaces the delivery archive. invoice-pdf-source gains the
'payment_confirmation' re-render reason so the UI caveats it like any
re-render. POST /api/invoices/[id]/send-payment-confirmation emails the
paid PDF with a dedicated subject/body through the existing email
service and recipient routing, without touching status, sent_at,
journal entries or invoice_deliveries (no kind column there; logged via
the route logger instead).

Detail page: the two actions sit inside the Betald card (download paid
copy, send confirmation with an up-front confirm dialog), not in the
header row. No migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): one-line hint for the betalningsbekräftelse actions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-19 20:17:18 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 926ca75fd3
commit 6b9338f17e
19 changed files with 1485 additions and 20 deletions
@@ -33,6 +33,7 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
}))
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 })
+32 -10
View File
@@ -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,
@@ -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()
})
})
@@ -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 },
)