Fix/invoice delivery and payment accounts (#1116)

* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
This commit is contained in:
Mattsson
2026-07-23 09:54:02 +02:00
committed by GitHub
parent 321e684523
commit 466e55a015
83 changed files with 6619 additions and 671 deletions
@@ -202,7 +202,7 @@ describe('POST /api/invoices/[id]/book', () => {
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: { ...invoice, journal_entry_id: 'je-1' }, error: null })
enqueue({ data: { document_attachment_id: 'document-1' }, error: null })
enqueue({ data: 'document-1', error: null })
const { status } = await parseJsonResponse(await bookRequest())
@@ -213,5 +213,9 @@ describe('POST /api/invoices/[id]/book', () => {
'document-1',
'je-1',
)
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'latest_sent_invoice_delivery_document',
{ p_company_id: 'company-1', p_invoice_id: 'inv-1' },
)
})
})
+7 -13
View File
@@ -126,16 +126,10 @@ export const POST = withRouteContext(
// The send flow archived the exact delivered PDF before this deferred
// journal entry existed. Attach the newest successful delivery snapshot now.
const { data: deliveryDocument, error: deliveryDocumentError } = await supabase
.from('invoice_deliveries')
.select('document_attachment_id')
.eq('invoice_id', id)
.eq('company_id', companyId)
.eq('status', 'sent')
.not('document_attachment_id', 'is', null)
.order('sent_at', { ascending: false })
.limit(1)
.maybeSingle()
const { data: deliveryDocumentId, error: deliveryDocumentError } = await supabase.rpc(
'latest_sent_invoice_delivery_document',
{ p_company_id: companyId, p_invoice_id: id },
)
if (deliveryDocumentError) {
log.error('failed to find delivered invoice PDF for deferred booking', deliveryDocumentError, {
@@ -145,18 +139,18 @@ export const POST = withRouteContext(
code: 'PDF_LINK_FAILED',
message: 'Fakturan bokfördes, men den arkiverade PDF-filen kunde inte kopplas till verifikationen.',
})
} else if (deliveryDocument?.document_attachment_id) {
} else if (typeof deliveryDocumentId === 'string') {
try {
await linkToJournalEntry(
supabase,
companyId!,
deliveryDocument.document_attachment_id,
deliveryDocumentId,
journalEntry.id,
)
} catch (err) {
log.error('failed to link delivered invoice PDF on deferred booking', err as Error, {
invoiceId: id,
documentId: deliveryDocument.document_attachment_id,
documentId: deliveryDocumentId,
})
warnings.push({
code: 'PDF_LINK_FAILED',
@@ -65,13 +65,14 @@ describe('GET /api/invoices/[id]/deliveries', () => {
expect(response.status).toBe(404)
})
it('returns minimized delivery metadata with masked recipient domains', async () => {
it('returns minimized delivery evidence for the active company', async () => {
const delivery = {
id: 'delivery-1',
channel: 'email',
status: 'sent',
to_addresses: ['customer@example.com'],
cc_addresses: [],
cc_addresses: ['accounts@example.com'],
bcc_addresses: ['archive@example.com'],
reply_to: 'sender@example.com',
from_name: 'Example AB',
subject: 'Faktura F-1001',
@@ -102,7 +103,7 @@ describe('GET /api/invoices/[id]/deliveries', () => {
channel: 'email',
status: 'sent',
to_addresses: ['***@example.com'],
cc_addresses: [],
cc_addresses: ['***@example.com'],
provider: 'resend',
error_code: null,
document_attachment_id: 'document-1',
@@ -110,15 +111,20 @@ describe('GET /api/invoices/[id]/deliveries', () => {
failed_at: null,
created_at: '2026-07-22T10:29:59.000Z',
}])
expect(body.data[0]).not.toHaveProperty('bcc_addresses')
expect(body.data[0]).not.toHaveProperty('reply_to')
expect(body.data[0]).not.toHaveProperty('from_name')
expect(body.data[0]).not.toHaveProperty('subject')
expect(body.data[0]).not.toHaveProperty('body_text')
expect(body.data[0]).not.toHaveProperty('body_html')
expect(body.data[0]).not.toHaveProperty('subject')
expect(body.data[0]).not.toHaveProperty('reply_to')
expect(body.data[0]).not.toHaveProperty('provider_message_id')
expect(body.data[0]).not.toHaveProperty('attachment_filename')
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_deliveries')
expect(mockSupabase.rpc).toHaveBeenCalledWith('list_invoice_delivery_summaries', {
p_company_id: 'company-1',
p_invoice_id: INVOICE_ID,
})
})
})
+38 -41
View File
@@ -2,48 +2,38 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { InvoiceDelivery } from '@/types'
import type { InvoiceDeliveryChannel, InvoiceDeliveryStatus } from '@/types'
type DeliveryListRow = Pick<
InvoiceDelivery,
| 'id'
| 'channel'
| 'status'
| 'to_addresses'
| 'cc_addresses'
| 'provider'
| 'error_code'
| 'document_attachment_id'
| 'sent_at'
| 'failed_at'
| 'created_at'
>
interface InvoiceDeliverySummaryRow {
id: string
channel: InvoiceDeliveryChannel
status: InvoiceDeliveryStatus
to_addresses: string[]
cc_addresses: string[]
provider: string | null
error_code: string | null
document_attachment_id: string | null
sent_at: string | null
failed_at: string | null
created_at: string
}
const DELIVERY_COLUMNS = [
'id',
'channel',
'status',
'to_addresses',
'cc_addresses',
'provider',
'error_code',
'document_attachment_id',
'sent_at',
'failed_at',
'created_at',
].join(', ')
type MaskedRecipientAddress = string & { readonly __maskedRecipientAddress: true }
function maskRecipientDomain(address: string): string {
const separator = address.lastIndexOf('@')
if (separator <= 0 || separator === address.length - 1) return '***'
return `***@${address.slice(separator + 1)}`
interface MaskedInvoiceDeliverySummaryRow
extends Omit<InvoiceDeliverySummaryRow, 'to_addresses' | 'cc_addresses'> {
to_addresses: MaskedRecipientAddress[]
cc_addresses: MaskedRecipientAddress[]
}
/**
* GET /api/invoices/[id]/deliveries
*
* Returns minimized delivery metadata for an invoice. Exact message content,
* provider identifiers, checksums, and full recipient addresses stay server-side.
* BCC recipients, provider identifiers, checksums, and full recipient
* addresses stay server-side. The database allow-list and masking boundary is
* defined by list_invoice_delivery_summaries in migration 20260723003000; this
* route masks returned addresses again as defense in depth.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.deliveries.list',
@@ -67,20 +57,19 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
const { data: deliveries, error } = await supabase
.from('invoice_deliveries')
.select(DELIVERY_COLUMNS)
.eq('invoice_id', id)
.eq('company_id', companyId)
.neq('status', 'preparing')
.order('created_at', { ascending: false })
const { data: deliveries, error } = await supabase.rpc(
'list_invoice_delivery_summaries',
{ p_company_id: companyId, p_invoice_id: id },
)
if (error) {
log.error('failed to list invoice deliveries', error, { invoiceId: id })
throw error
}
const minimized = ((deliveries || []) as unknown as DeliveryListRow[]).map((delivery) => ({
const minimized: MaskedInvoiceDeliverySummaryRow[] = (
(deliveries || []) as unknown as InvoiceDeliverySummaryRow[]
).map((delivery) => ({
id: delivery.id,
channel: delivery.channel,
status: delivery.status,
@@ -100,3 +89,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
)
},
)
function maskRecipientDomain(address: string): MaskedRecipientAddress {
const separator = address.lastIndexOf('@')
if (separator <= 0 || separator === address.length - 1) {
return '***' as MaskedRecipientAddress
}
return `***@${address.slice(separator + 1)}` as MaskedRecipientAddress
}
@@ -29,6 +29,11 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockEnsureInvoiceNumber = vi.fn()
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureInvoiceNumber(...args),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
@@ -86,6 +91,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
const company = makeCompanySettings({
accounting_method: 'accrual',
entity_type: 'enskild_firma',
bankgiro: '123-4567',
})
const invoice = makeInvoice({
id: 'inv-1',
@@ -162,6 +168,43 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(status).toBe(400)
})
it.each(['SEK', 'EUR'] as const)(
'rejects a %s invoice without a payment account before number allocation',
async (currency) => {
enqueue({
data: makeInvoice({
...invoice,
invoice_number: null,
currency,
}),
error: null,
})
enqueue({
data: {
...company,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
},
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/mark-sent', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockEnsureInvoiceNumber).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(mockRenderToBuffer).not.toHaveBeenCalled()
},
)
it('archives the rendered PDF as underlag linked to the journal entry', async () => {
enqueue({ data: invoice, error: null }) // fetch invoice
enqueue({ data: company, error: null }) // settings
@@ -181,6 +224,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-7')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockRecordManualInvoiceDelivery).toHaveBeenCalledWith({
supabase: mockSupabase,
companyId: 'company-1',
+35 -17
View File
@@ -17,6 +17,10 @@ import { recordManualInvoiceDelivery } from '@/lib/invoices/invoice-deliveries'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type {
@@ -97,14 +101,6 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
const customLines = linesResult.lines
// Assign invoice number now if this draft doesn't have one yet
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on mark-sent', err as Error)
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
// Fetch full company settings for PDF rendering and accounting method
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
@@ -116,6 +112,23 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId })
}
const invoiceCurrency = (invoice as Invoice).currency
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
if (!hasRequiredInvoicePaymentAccount(settings as CompanySettings, invoice as Invoice)) {
return errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: invoiceCurrency },
})
}
// Assign the number only after all payment-instruction guards pass.
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on mark-sent', err as Error)
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
const accountingMethod = (settings.accounting_method || 'accrual') as AccountingMethod
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
let originalInvoice: CreditNoteOriginalInvoice | undefined
@@ -359,8 +372,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
settings as CompanySettings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(settings as CompanySettings, renderableInvoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -425,14 +440,17 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
})
}
return NextResponse.json({
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
})
return NextResponse.json(
{
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
},
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
{ requireWrite: true },
)
@@ -37,7 +37,7 @@ import { GET } from '../route'
describe('GET /api/invoices/[id]/pdf', () => {
const user = { id: 'user-1', email: 'owner@example.test' }
const customer = makeCustomer({ name: 'Kund ÅÄÖ AB' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
const invoice = makeInvoice({
id: 'invoice-1',
invoice_number: '2621',
@@ -77,6 +77,7 @@ describe('GET /api/invoices/[id]/pdf', () => {
)
expect(response.status).toBe(404)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns a descriptive UTF-8 filename for the PDF download', async () => {
@@ -91,5 +92,36 @@ describe('GET /api/invoices/[id]/pdf', () => {
expect(response.status).toBe(200)
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns 400 before rendering when a foreign payment account is missing', async () => {
enqueue({ data: { ...invoice, currency: 'EUR' }, error: null })
enqueue({ data: { ...company, invoice_payment_accounts: {} }, error: null })
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(renderToBufferMock).not.toHaveBeenCalled()
})
it('marks PDF generation errors as private and non-cacheable', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
const response = await GET(
createMockRequest('/api/invoices/invoice-1/pdf'),
createMockRouteParams({ id: 'invoice-1' }),
)
expect(response.status).toBe(500)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
})
+38 -6
View File
@@ -7,12 +7,27 @@ import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.pdf',
async (request, { supabase, companyId }, { params }) => {
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
// withRouteContext resolves companyId from the authenticated user's active
// membership. Explicit company filters remain mandatory defense in depth.
// Fetch invoice with customer and items
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
@@ -26,7 +41,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
return NextResponse.json(
{ error: 'Invoice not found' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
// Fetch company settings
@@ -37,7 +55,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.single()
if (companyError || !company) {
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
return NextResponse.json(
{ error: 'Company settings not found' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, invoice as Invoice)) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: (invoice as Invoice).currency },
}))
}
// Sort items by sort_order
@@ -50,6 +78,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
.from('invoices')
.select('invoice_number')
.eq('id', invoice.credited_invoice_id)
.eq('company_id', companyId)
.single()
if (originalInvoice) {
@@ -61,8 +90,10 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
// Generate PDF
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
(invoice as Invoice).currency,
{ paymentAccountRequired: invoiceRequiresPaymentAccount(invoice as Invoice) },
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, invoice as Invoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, invoice as Invoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(invoice as Invoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -98,13 +129,14 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('attachment', filename),
'Content-Length': pdfBuffer.length.toString(),
'Cache-Control': 'private, no-store',
},
})
} catch (error) {
console.error('PDF generation error:', error)
log.error('invoice PDF generation failed', error, { requestId, invoiceId: id })
return NextResponse.json(
{ error: error instanceof Error ? getUserErrorMessage(error) : 'PDF generation failed' },
{ status: 500 }
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
)
}
},
@@ -58,6 +58,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
emailService: { sendEmail: (options: unknown) => Promise<Record<string, unknown>> }
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject: string
html: string
text: string
@@ -69,6 +70,7 @@ const mockSendTrackedInvoiceEmail = vi.fn(async (input: {
...(await input.emailService.sendEmail({
to: input.to,
cc: input.cc,
bcc: input.bcc,
subject: input.subject,
html: input.html,
text: input.text,
@@ -135,7 +137,7 @@ import { POST } from '../route'
describe('POST /api/invoices/[id]/send', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual' })
const company = makeCompanySettings({ accounting_method: 'accrual', bankgiro: '123-4567' })
const invoice = makeInvoice({
id: 'inv-1',
status: 'draft',
@@ -316,6 +318,26 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
})
it('returns 400 when the stored customer email is malformed', async () => {
enqueue({
data: makeInvoice({
id: 'inv-1',
customer: makeCustomer({ email: 'not-an-email' }),
items: [],
}),
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_NO_CUSTOMER_EMAIL')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('returns 404 when company settings not found', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: null, error: { message: 'Not found' } })
@@ -328,11 +350,155 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_COMPANY_SETTINGS_MISSING')
})
it.each(['SEK', 'EUR'] as const)(
'does not allocate a number or send a %s invoice without a matching payment account',
async (currency) => {
const invoiceWithoutAccount = makeInvoice({
...invoice,
invoice_number: null,
currency,
})
enqueue({ data: invoiceWithoutAccount, error: null })
enqueue({
data: {
...company,
invoice_payment_accounts: {},
clearing_number: null,
account_number: null,
bankgiro: null,
plusgiro: null,
swish: null,
iban: null,
},
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
},
)
it('rejects custom recipients from a non-admin company member before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: { role: 'member' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_cc: ['external@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects a custom recipient collision before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
enqueue({ data: { role: 'admin' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_cc: ['KUND@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { collisions: Array<{ conflicts_with: string }> } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.collisions).toEqual([
expect.objectContaining({ conflicts_with: 'to' }),
])
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects a combined recipient set over the limit before allocation', async () => {
enqueue({ data: invoice, error: null })
enqueue({
data: {
...company,
invoice_email_cc_addresses: Array.from(
{ length: 19 },
(_, index) => `fixed-${index}@test.se`,
),
invoice_email_bcc_addresses: [],
},
error: null,
})
enqueue({ data: { role: 'admin' }, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: { additional_bcc: ['archive@test.se'] },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { recipient_count: number } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('rejects fixed routing over the total limit without a custom-recipient role query', async () => {
enqueue({ data: invoice, error: null })
enqueue({
data: {
...company,
email: 'legacy@test.se',
invoice_email_cc_addresses: Array.from(
{ length: 19 },
(_, index) => `fixed-${index}@test.se`,
),
invoice_email_bcc_addresses: ['archive@test.se'],
},
error: null,
})
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { recipient_count: number } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_TOO_MANY_RECIPIENTS')
expect(body.error.details.recipient_count).toBe(21)
expect(mockReserveInvoiceDelivery).not.toHaveBeenCalled()
expect(mockSendEmail).not.toHaveBeenCalled()
})
it('sends invoice email, updates status, creates journal entry for accrual', async () => {
// Fetch invoice
enqueue({ data: invoice, error: null })
// Fetch company settings
enqueue({ data: company, error: null })
enqueue({
data: {
...company,
invoice_email_cc_addresses: ['fixed-copy@test.se'],
invoice_email_bcc_addresses: ['fixed-archive@test.se'],
},
error: null,
})
// Authorize the per-send CC and BCC additions.
enqueue({ data: { role: 'owner' }, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
@@ -344,22 +510,38 @@ describe('POST /api/invoices/[id]/send', () => {
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const request = createMockRequest('/api/invoices/inv-1/send', {
method: 'POST',
body: {
additional_cc: ['case-owner@test.se'],
additional_bcc: ['extra-archive@test.se'],
},
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
messageId: string
recipient_counts: { to: number; cc: number }
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.messageId).toBe('msg-1')
expect(body.recipient_counts).toEqual({ to: 1, cc: 2 })
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(mockSendTrackedInvoiceEmail).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'company-1', invoiceId: 'inv-1' }),
expect.objectContaining({
companyId: 'company-1',
invoiceId: 'inv-1',
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
}),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'kund@test.se',
to: ['kund@test.se'],
cc: ['fixed-copy@test.se', 'case-owner@test.se'],
bcc: ['fixed-archive@test.se', 'extra-archive@test.se'],
subject: 'Faktura F-2024001',
})
)
@@ -381,6 +563,7 @@ describe('POST /api/invoices/[id]/send', () => {
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: 'inv-1',
currency: 'EUR',
customer,
items: (invoice.items ?? []).map((item) => ({
...item,
@@ -426,6 +609,9 @@ describe('POST /api/invoices/[id]/send', () => {
}),
)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(InvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ originalInvoiceNumber: 'F-2024001' }),
)
expect(mockSendEmail).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [
@@ -516,7 +702,7 @@ describe('POST /api/invoices/[id]/send', () => {
})
it('skips journal entry for cash method', async () => {
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
const cashCompany = makeCompanySettings({ accounting_method: 'cash', bankgiro: '123-4567' })
enqueue({ data: invoice, error: null })
enqueue({ data: cashCompany, error: null })
+125 -14
View File
@@ -27,7 +27,19 @@ import {
InvoiceDeliverySnapshotError,
} from '@/lib/invoices/invoice-deliveries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { SendInvoiceSchema } from '@/lib/api/schemas'
import { parseCustomIssuanceLines } from '@/lib/invoices/issuance-custom-lines'
import {
EMAIL_PATTERN,
exceedsInvoiceEmailRecipientLimit,
findAdditionalInvoiceRecipientCollisions,
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'
@@ -118,7 +130,18 @@ export const POST = withRouteContext(
})
}
const linesResult = parseCustomIssuanceLines(rawBody)
const bodyResult = SendInvoiceSchema.safeParse(rawBody ?? {})
if (!bodyResult.success) {
opLog.warn('send validation failed')
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: bodyResult.error.flatten() },
{ status: 400 },
)
}
const linesResult = parseCustomIssuanceLines(
bodyResult.data.lines ? { lines: bodyResult.data.lines } : undefined,
)
if (!linesResult.ok) {
if (linesResult.error === 'invalid_body') {
opLog.warn('send validation failed')
@@ -147,7 +170,7 @@ export const POST = withRouteContext(
}
const customer = invoice.customer as Customer
if (!customer.email) {
if (!customer.email?.trim() || !EMAIL_PATTERN.test(customer.email.trim())) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
requestId,
details: { customerId: customer.id },
@@ -164,6 +187,72 @@ export const POST = withRouteContext(
return errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', opLog, { requestId })
}
const invoiceCurrency = (invoice as Invoice).currency
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: invoiceCurrency },
})
}
const hasAdditionalRecipients =
(bodyResult.data.additional_cc?.length ?? 0) > 0
|| (bodyResult.data.additional_bcc?.length ?? 0) > 0
// Fixed recipients are owner/admin-approved company routing and apply to
// every writable sender. Only a new per-send disclosure needs this fresh
// role check. See .compliance/authorization-policy.md.
if (hasAdditionalRecipients) {
const { data: membership, error: membershipError } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
opLog.error('failed to authorize custom invoice recipients', membershipError)
return errorResponseFromCode('INTERNAL_ERROR', opLog, { requestId })
}
if (!membership || !['owner', 'admin'].includes(membership.role)) {
return errorResponseFromCode('FORBIDDEN', opLog, {
requestId,
details: { required_roles: ['owner', 'admin'] },
})
}
}
const recipientInput = {
to: customer.email,
configuredCc: company.invoice_email_cc_addresses,
configuredBcc: company.invoice_email_bcc_addresses,
// This value comes from company settings or the authenticated sender. It
// is fixed routing, not an arbitrary request-controlled recipient.
legacyCc: company.email || user.email,
additionalCc: bodyResult.data.additional_cc,
additionalBcc: bodyResult.data.additional_bcc,
}
const recipientCollisions = findAdditionalInvoiceRecipientCollisions(recipientInput)
if (recipientCollisions.length > 0) {
return errorResponseFromCode('VALIDATION_ERROR', opLog, {
requestId,
details: { field: 'recipients', collisions: recipientCollisions },
})
}
const recipients = resolveInvoiceEmailRecipients(recipientInput)
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 originalInvoice: CreditNoteOriginalInvoice | undefined
@@ -190,7 +279,11 @@ export const POST = withRouteContext(
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
const preflight = await prepareInvoicePdfRender(company as CompanySettings)
const preflight = await prepareInvoicePdfRender(
company as CompanySettings,
(invoice as Invoice).currency,
{ paymentAccountRequired },
)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
@@ -253,8 +346,10 @@ export const POST = withRouteContext(
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
renderableInvoice.currency,
{ paymentAccountRequired },
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, renderableInvoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -285,7 +380,6 @@ export const POST = withRouteContext(
isCreditNote,
})
const ccAddress = company.email || user.email
const partialFailures: Array<{ step: string; reason: string }> = []
if (paymentLinkFailure) {
// The failure string is a raw provider/DB message: log it, but the
@@ -377,8 +471,9 @@ export const POST = withRouteContext(
userId: user.id,
invoiceId: id,
deliveryId,
to: customer.email,
cc: ccAddress,
to: recipients.to,
cc: recipients.cc,
bcc: recipients.bcc,
subject,
html,
text,
@@ -575,15 +670,31 @@ export const POST = withRouteContext(
})
}
return NextResponse.json({
success: true,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email} (kopia till ${ccAddress})`,
messageId: result.messageId,
opLog.info('invoice sent', {
deliveryId: result.deliveryId,
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
messageId: result.messageId,
recipientCounts: {
to: recipients.to.length,
cc: recipients.cc.length,
},
})
return NextResponse.json(
{
success: true,
message: `${isCreditNote ? 'Kreditfakturan' : 'Fakturan'} har skickats till ${customer.email}`,
messageId: result.messageId,
deliveryId: result.deliveryId,
recipient_counts: {
to: recipients.to.length,
cc: recipients.cc.length,
},
...(partialFailures.length > 0
? { partial: true, partial_failures: partialFailures }
: {}),
},
{ headers: { 'Cache-Control': 'private, no-store' } },
)
},
{ requireWrite: true },
)
@@ -40,7 +40,7 @@ import { POST } from '../route'
describe('POST /api/invoices/preview-pdf', () => {
const user = { id: 'user-1', email: 'owner@example.test' }
const customer = makeCustomer({ id: 'customer-1', name: 'Kund ÅÄÖ AB' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige' })
const company = makeCompanySettings({ company_name: 'Oppy Sverige', bankgiro: '123-4567' })
const validBody = {
customer_id: customer.id,
invoice_number: '2621',
@@ -88,9 +88,11 @@ describe('POST /api/invoices/preview-pdf', () => {
)
expect(response.status).toBe(400)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns 404 when the customer does not exist', async () => {
enqueue({ data: company, error: null })
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
@@ -99,11 +101,12 @@ describe('POST /api/invoices/preview-pdf', () => {
)
expect(response.status).toBe(404)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
it('returns a descriptive UTF-8 filename for the PDF preview', async () => {
enqueue({ data: customer, error: null })
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
@@ -112,7 +115,41 @@ describe('POST /api/invoices/preview-pdf', () => {
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/pdf')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(contentDispositionFilename(response.headers.get('Content-Disposition')))
.toBe('Oppy Sverige x Kund ÅÄÖ AB Faktura nr 2621 20260721.pdf')
})
it('returns 400 when a foreign payment account is missing', async () => {
enqueue({ data: company, error: null })
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', {
method: 'POST',
body: { ...validBody, currency: 'EUR' },
}),
createMockRouteParams({}),
)
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING')
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(renderToBufferMock).not.toHaveBeenCalled()
expect(mockSupabase.from).not.toHaveBeenCalledWith('customers')
})
it('marks preview generation errors as private and non-cacheable', async () => {
enqueue({ data: company, error: null })
enqueue({ data: customer, error: null })
renderToBufferMock.mockRejectedValueOnce(new Error('render failed'))
const response = await POST(
createMockRequest('/api/invoices/preview-pdf', { method: 'POST', body: validBody }),
createMockRouteParams({}),
)
expect(response.status).toBe(500)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
})
})
+68 -20
View File
@@ -7,6 +7,18 @@ import { getVatRules } from '@/lib/invoices/vat-rules'
import { invoicePdfFilename } from '@/lib/invoices/pdf-filename'
import { contentDisposition } from '@/lib/api/content-disposition'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
hasRequiredInvoicePaymentAccount,
invoiceRequiresPaymentAccount,
} from '@/lib/invoices/payment-accounts'
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
function privateNoStore(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'private, no-store')
return response
}
/**
* POST /api/invoices/preview-pdf
@@ -14,7 +26,13 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy
* Generates a preview PDF from form data without creating an invoice.
* Returns the PDF as an inline blob for display in a new browser tab.
*/
export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => {
export const POST = withRouteContext('invoice.preview_pdf', async (request, {
supabase,
user,
companyId,
log,
requestId,
}) => {
const body = await request.json()
const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number, payment_link_url } = body
@@ -30,7 +48,40 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
})()
if (!items || items.length === 0) {
return NextResponse.json({ error: 'Rader krävs' }, { status: 400 })
return NextResponse.json(
{ error: 'Rader krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const docType: InvoiceDocumentType = document_type || 'invoice'
const requestedCurrency = currency || 'SEK'
// Fetch and validate company payment settings before customer data. The
// preview performs no writes, but a request that cannot be rendered should
// still stop before processing customer details.
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return NextResponse.json(
{ error: 'Företagsinställningar saknas' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
if (!hasRequiredInvoicePaymentAccount(company as CompanySettings, {
currency: requestedCurrency,
document_type: docType,
credited_invoice_id: null,
})) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_PAYMENT_ACCOUNT_MISSING', log, {
requestId,
details: { currency: requestedCurrency },
}))
}
// When customer_id is omitted, only allow the synthetic preview if the
@@ -47,7 +98,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
.eq('company_id', companyId)
if (countError || (count ?? 0) > 0) {
return NextResponse.json({ error: 'Kunduppgifter krävs' }, { status: 400 })
return NextResponse.json(
{ error: 'Kunduppgifter krävs' },
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS },
)
}
const nowIso = new Date().toISOString()
@@ -85,26 +139,17 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
.single()
if (customerError || !data) {
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
return NextResponse.json(
{ error: 'Kunden hittades inte' },
{ status: 404, headers: PRIVATE_NO_STORE_HEADERS },
)
}
customer = data as Customer
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', companyId)
.single()
if (companyError || !company) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
// VAT rules are customer-type-driven and only know the customer side.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const docType: InvoiceDocumentType = document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
// VAT registration gate: mirror the server-side write gate
@@ -154,7 +199,7 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
status: 'draft',
currency: currency || 'SEK',
currency: requestedCurrency,
exchange_rate: null,
exchange_rate_date: null,
subtotal: isDeliveryNote ? 0 : subtotal,
@@ -183,8 +228,10 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
try {
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
previewInvoice.currency,
{ paymentAccountRequired: invoiceRequiresPaymentAccount(previewInvoice) },
)
const swishQrDataUrl = await buildSwishQrDataUrl(company as CompanySettings, previewInvoice)
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, previewInvoice)
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(previewInvoice)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
@@ -211,13 +258,14 @@ export const POST = withRouteContext('invoice.preview_pdf', async (request, { su
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('inline', filename),
'Cache-Control': 'private, no-store',
},
})
} catch (error) {
console.error('Preview PDF generation error:', error)
log.error('invoice preview PDF generation failed', error, { requestId })
return NextResponse.json(
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
{ status: 500 }
{ status: 500, headers: PRIVATE_NO_STORE_HEADERS }
)
}
})