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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
926ca75fd3
commit
6b9338f17e
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user