Feat/voucher docs (#664)

* feat: implement inbox document picker and linking functionality

* feat: implement self-billing invoice functionality

- Added support for registering self-billed invoices received from customers.
- Updated the invoice schema to include fields for self-billing metadata such as `is_self_billed`, `external_invoice_number`, `self_billing_agreement_ref`, and `received_date`.
- Created API route for handling self-billed invoice submissions, including validation and error handling.
- Implemented database migrations to add necessary columns and constraints for self-billing invoices.
- Developed tests to ensure correct behavior of self-billing invoice creation and validation rules.
- Updated Swedish localization files to include new terms related to self-billing.

* feat: enforce SIE import requirement for non-Fortnox providers in migration process

* feat: streamline invoice processing and enhance error logging across APIs
This commit is contained in:
Mattsson
2026-06-04 13:14:57 +02:00
committed by GitHub
parent c1be9f15dd
commit 3e42fc6f32
45 changed files with 2675 additions and 529 deletions
+17 -4
View File
@@ -9,6 +9,7 @@ import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createLogger } from '@/lib/logger'
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
ensureInitialized()
@@ -37,6 +38,7 @@ export async function POST(
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id })
// Fetch invoice
const { data: invoice, error: invoiceError } = await supabase
@@ -61,7 +63,7 @@ export async function POST(
try {
await ensureInvoiceNumber(supabase, companyId, invoice as Invoice)
} catch (err) {
console.error('Failed to assign invoice number on mark-sent:', err)
log.error('failed to assign invoice number on mark-sent', err as Error)
return NextResponse.json(
{ error: 'Kunde inte tilldela fakturanummer. Försök igen.' },
{ status: 500 }
@@ -103,13 +105,24 @@ export async function POST(
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
if (linkError) {
// Don't fail mark-sent — the verifikat committed; only the link
// failed. But log it through the structured logger so it reaches log
// aggregation/alerting: this write silently no-ops when the
// journal_entry_id column is missing (it was absent in prod until the
// 20260613100000 migration), which leaves mark-paid unable to detect
// an already-booked sale.
log.error('mark-sent: journal_entry_id link to invoice failed', linkError, {
journalEntryId: journalEntry.id,
})
}
}
} catch (err) {
console.error('Failed to create invoice journal entry on mark-sent:', err)
log.error('failed to create invoice journal entry on mark-sent', err as Error)
}
}
@@ -162,7 +175,7 @@ export async function POST(
journal_entry_id: journalEntryId ?? undefined,
})
} catch (err) {
console.error('Failed to archive invoice PDF on mark-sent:', err)
log.error('failed to archive invoice PDF on mark-sent', err as Error)
}
}
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
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 mockGetVatRules = vi.fn()
const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue(null),
convertToSEK: vi.fn(),
}))
const mockCreateInvoiceJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateInvoiceJournalEntry(...args),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const validBody = {
customer_id: VALID_UUID,
external_invoice_number: 'KUND-55012',
self_billing_agreement_ref: 'Avtal 2026-01',
invoice_date: '2026-06-01',
received_date: '2026-06-02',
due_date: '2026-06-30',
currency: 'SEK',
items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }],
}
function mockDomesticVat() {
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
reverseChargeText: null,
})
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
}
describe('POST /api/invoices/self-billed', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 400 when external_invoice_number is missing', async () => {
const { external_invoice_number, ...rest } = validBody
void external_invoice_number
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: rest })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ type: string }>(response)
expect(status).toBe(400)
expect(body.type).toBe('validation_error')
})
it('returns 404 when the customer (issuer) is not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
})
it('rejects an item VAT rate the customer is not allowed to use', async () => {
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null })
// Domestic-only set: 0% is NOT allowed for this customer.
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
])
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null })
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: { ...validBody, items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 0 }] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION')
})
it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' })
const created = makeInvoice({
id: 'inv-1',
invoice_number: null,
is_self_billed: true,
external_invoice_number: 'KUND-55012',
total: 12500,
})
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: null, error: null }) // update journal_entry_id
enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
// Booked as a sale with the self-billing label + the counterparty's number.
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledTimes(1)
const opts = mockCreateInvoiceJournalEntry.mock.calls[0][6]
expect(opts).toEqual({ descriptionPrefix: 'Självfaktura', numberOverride: 'KUND-55012' })
// Never consumes our own invoice-number series.
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
})
it('does NOT book at registration under kontantmetoden (cash) — books at payment instead', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID })
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch final
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('rolls back when there is no open fiscal period for the invoice date', async () => {
mockDomesticVat()
const customer = makeCustomer({ id: VALID_UUID })
const created = makeInvoice({ id: 'inv-1', invoice_number: null, is_self_billed: true, external_invoice_number: 'KUND-55012' })
enqueue({ data: customer, error: null }) // fetch customer
enqueue({ data: created, error: null }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete
enqueue({ data: null, error: null }) // rollback delete
mockCreateInvoiceJournalEntry.mockResolvedValue(null) // no fiscal period
const request = createMockRequest('/api/invoices/self-billed', { method: 'POST', body: validBody })
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
})
it('rejects a foreign-currency self-billed invoice when no FX rate is available', async () => {
mockDomesticVat()
// fetchExchangeRate is mocked to resolve null (rate unavailable for the
// invoice date). Booking would otherwise fall through to a silent 1:1 SEK
// conversion, so the route must refuse up front — before any insert.
enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null }) // fetch customer
const request = createMockRequest('/api/invoices/self-billed', {
method: 'POST',
body: { ...validBody, currency: 'EUR' },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ type: string; error: string }>(response)
expect(status).toBe(400)
expect(body.error).toMatch(/växelkurs/i)
// Never books a wrong-magnitude verifikat and never inserts the invoice.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
})
+311
View File
@@ -0,0 +1,311 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateSelfBillingInvoiceSchema } from '@/lib/api/schemas'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { roundOre } from '@/lib/money'
import type { EntityType, Invoice } from '@/types'
ensureInitialized()
/**
* POST /api/invoices/self-billed
*
* Register a self-billing invoice we RECEIVED (mottagen självfaktura, ML 17 kap
* 15§). The customer issued the invoice on our behalf; for us it is a sale, so
* it books exactly like a customer invoice (Debit 1510, Credit 30xx + 26xx) and
* the output VAT lands in our momsdeklaration.
*
* It differs from a normal customer invoice in two ways:
* - We do NOT assign a number from our own series — the counterparty's number
* is stored in external_invoice_number and our invoice_number stays null
* (BFL 5 kap 6§). Enforced by the invoices_self_billed_numbering constraint.
* - There is no send step. Under faktureringsmetoden (accrual) we book the
* registration entry here. Under kontantmetoden (cash) we leave it unbooked
* until payment — identical to a normal invoice — and the existing mark-paid
* flow books the cash entry then.
*
* Payment is handled by the existing flows: the row is created with status
* 'sent', so "Markera som betald" / bank matching work unchanged.
*/
export const POST = withRouteContext(
'invoice.self_billed.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON in request body', type: 'validation_error' },
{ status: 400 },
)
}
const parsed = CreateSelfBillingInvoiceSchema.safeParse(rawBody)
if (!parsed.success) {
log.warn('self-billed invoice validation failed', { issueCount: parsed.error.issues.length })
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
},
{ status: 400 },
)
}
const input = parsed.data
// The issuer of a self-billing invoice is, in our books, the customer we
// sold to. Require an existing customer row so VAT rules + reporting work.
// Project only the fields used below (data minimisation — GDPR Art. 25 /
// SOC 2 CC6.3): VAT treatment derivation and the verifikat description.
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id, name, customer_type, vat_number_validated')
.eq('id', input.customer_id)
.eq('company_id', companyId!)
.single()
if (customerError || !customer) {
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
requestId,
details: { customerId: input.customer_id },
})
}
// VAT treatment is driven by who the customer is (domestic / EU reverse
// charge / export), exactly like an own-issued invoice.
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
let vatAmount = 0
for (const item of input.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
if (!allowedRates.has(itemRate)) {
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
requestId,
details: {
attemptedRate: itemRate,
allowedRates: Array.from(allowedRates),
customerType: customer.customer_type,
},
})
}
const lineTotal = item.quantity * item.unit_price
vatAmount += roundOre((lineTotal * itemRate) / 100)
}
const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
const total = roundOre(subtotal + vatAmount)
const uniqueRates = new Set(input.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Foreign currency: convert using the rate on the INVOICE date (ML 7 kap 7§),
// not today's rate.
let exchangeRate: number | null = null
let exchangeRateDate: string | null = null
let subtotalSek: number | null = null
let vatAmountSek: number | null = null
let totalSek: number | null = null
if (input.currency !== 'SEK') {
const rateData = await fetchExchangeRate(input.currency, new Date(input.invoice_date))
if (!rateData) {
// No FX rate for the invoice date — refuse rather than letting the
// booking fall through to resolveSekAmount's legacy 1:1 fallback, which
// would treat e.g. 1 000 USD as 1 000 SEK and commit a balanced but
// silently wrong-magnitude verifikat. ML 7 kap 7§ requires the
// invoice-date rate; we never substitute today's. The user can retry
// once the rate is published.
log.warn('self-billed invoice rejected: no FX rate for invoice date', {
currency: input.currency,
invoiceDate: input.invoice_date,
})
return NextResponse.json(
{
error: `Kunde inte hämta växelkurs för ${input.currency} på fakturadatumet (${input.invoice_date}). Försök igen senare.`,
type: 'validation_error',
},
{ status: 400 },
)
}
exchangeRate = rateData.rate
exchangeRateDate = rateData.date
subtotalSek = convertToSEK(subtotal, exchangeRate)
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
totalSek = convertToSEK(total, exchangeRate)
}
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
company_id: companyId,
customer_id: input.customer_id,
// No own number — the counterparty's number lives in external_invoice_number.
invoice_number: null,
is_self_billed: true,
external_invoice_number: input.external_invoice_number,
self_billing_agreement_ref: input.self_billing_agreement_ref ?? null,
received_date: input.received_date,
invoice_date: input.invoice_date,
due_date: input.due_date,
// Booked + awaiting/with payment — never a draft, so it shows in the AR
// ledger and is payable via the existing mark-paid / matching flows.
status: 'sent',
currency: input.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
total,
total_sek: totalSek,
remaining_amount: total,
vat_treatment: vatRules.treatment,
vat_rate: isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
notes: input.notes,
document_type: 'invoice',
})
.select()
.single()
if (invoiceError || !invoice) {
log.error('self-billed invoice insert failed', invoiceError)
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
details: { pgCode: invoiceError?.code, pgMessage: invoiceError?.message },
})
}
const items = input.items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
return {
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: roundOre((lineTotal * itemRate) / 100),
}
})
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
if (itemsError) {
// The item insert failed, so nothing was written there — just remove the
// orphaned invoice header.
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice items insert failed; rolled back', itemsError, { invoiceId: invoice.id })
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
requestId,
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
})
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId!)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// Faktureringsmetoden: book the registration entry now (Debit 1510, Credit
// 30xx + 26xx). Kontantmetoden: leave unbooked until payment, exactly like a
// normal invoice — the mark-paid flow books the cash entry then.
if (accountingMethod === 'accrual') {
if (!completeInvoice) {
// The row was inserted but the re-fetch came back empty (transient DB
// issue). Roll back rather than crash on a null cast inside the engine —
// and surface it as a fetch failure, not an opaque booking error.
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('self-billed invoice re-fetch returned no row before booking; rolled back', undefined, {
invoiceId: invoice.id,
})
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
requestId,
details: { stage: 'refetch_before_booking' },
})
}
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
companyId!,
user.id,
completeInvoice as Invoice,
entityType,
customer.name,
{ descriptionPrefix: 'Självfaktura', numberOverride: input.external_invoice_number },
)
if (!journalEntry) {
// No open fiscal period for the invoice date — roll the row back so we
// never leave an unbooked self-billing sale sitting as 'sent'.
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json(
{ error: 'Ingen öppen bokföringsperiod för fakturadatumet', type: 'validation_error' },
{ status: 400 },
)
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
.eq('company_id', companyId!)
if (linkError) {
// The verifikat is already committed (immutable) — don't roll it back
// over a failed convenience link. Log loudly: this is the exact write
// that silently no-ops if the journal_entry_id column is ever missing
// again (it was absent in prod for months before 20260613100000).
log.error('self-billed invoice booked but journal_entry_id link failed', linkError, {
invoiceId: invoice.id,
journalEntryId: journalEntry.id,
})
}
} catch (err) {
await supabase.from('invoices').delete().eq('id', invoice.id)
log.error('failed to book self-billed invoice; rolled back', err as Error, { invoiceId: invoice.id })
return errorResponse(err, log, { requestId })
}
}
const { data: finalInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
// The invoice is committed (and, under accrual, booked) by this point. If the
// final re-fetch comes back empty under transient load, fall back to the
// shapes we already hold so the 200 always carries a usable id — otherwise
// the client's redirect to /invoices/{id} would throw on a null result.
const responseInvoice = (finalInvoice ?? completeInvoice ?? invoice) as Invoice
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: responseInvoice, companyId: companyId!, userId: user.id },
})
return NextResponse.json({ data: responseInvoice })
},
{ requireWrite: true },
)