* fix(settings): scope cross-field VAT validations to saves that touch them
The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").
Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): gate issuance on the seller VAT number (skeptic finding)
The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).
Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.
Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.
The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths
Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.
Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* test(email): refresh brand-mail snapshots for the coherent VAT fixture
The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
|
import { booksInvoicesOnIssue } from '@/lib/bookkeeping/booking-mode'
|
|
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
import type { CustomIssuanceLine } from '@/lib/invoices/issuance-custom-lines'
|
|
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 { hasRequiredSellerVatNumber } from '@/lib/invoices/seller-vat-number'
|
|
import { uploadDocument } from '@/lib/core/documents/document-service'
|
|
import type { Logger } from '@/lib/logger'
|
|
import type {
|
|
CompanySettings,
|
|
Customer,
|
|
EntityType,
|
|
Invoice,
|
|
InvoiceItem,
|
|
} from '@/types'
|
|
|
|
export interface IssuePartialFailure {
|
|
step: string
|
|
reason: string
|
|
}
|
|
|
|
/** Joined invoice row as the issuance flows fetch it. */
|
|
export type IssuableInvoice = Invoice & {
|
|
customer?: (Customer & { name?: string }) | null
|
|
items?: InvoiceItem[] | null
|
|
}
|
|
|
|
export type IssueAndBookResult =
|
|
| {
|
|
ok: true
|
|
journalEntryId: string | null
|
|
partialFailures: IssuePartialFailure[]
|
|
}
|
|
| { ok: false; errorCode: string; details?: Record<string, unknown> }
|
|
|
|
export interface IssueAndBookOptions {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
userId: string
|
|
/**
|
|
* The draft invoice (with customer + items joined). Never a credit note:
|
|
* credit-note issuance lives in issue-credit-note.ts and stays on the
|
|
* mark-sent route.
|
|
*/
|
|
invoice: IssuableInvoice
|
|
settings: CompanySettings
|
|
log: Logger
|
|
/**
|
|
* User-edited journal lines from the mark-sent body. Bulk paths pass none:
|
|
* generated lines book as-is.
|
|
*/
|
|
customLines?: CustomIssuanceLine[] | null
|
|
}
|
|
|
|
/**
|
|
* Archive the issued invoice's PDF as underlag so it remains retrievable even
|
|
* if the invoice row is later cancelled. Shared between the mark-sent route
|
|
* (real invoices and credit notes) and the bulk Bokför flow. Returns a partial
|
|
* failure instead of throwing: the issuance itself already committed.
|
|
*/
|
|
export async function archiveIssuedInvoicePdf(args: {
|
|
supabase: SupabaseClient
|
|
companyId: string
|
|
userId: string
|
|
invoice: IssuableInvoice
|
|
settings: CompanySettings
|
|
journalEntryId: string | null
|
|
originalInvoiceNumber?: string
|
|
log: Logger
|
|
}): Promise<IssuePartialFailure | null> {
|
|
const { supabase, companyId, userId, invoice, settings, journalEntryId, log } = args
|
|
try {
|
|
const items = ((invoice.items as InvoiceItem[] | null) ?? [])
|
|
.slice()
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
|
|
// The DB status flip already happened, but the in-memory `invoice` is
|
|
// stale and still reads 'draft': override here so the archived underlag
|
|
// isn't stamped "UTKAST: inte en giltig faktura".
|
|
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
|
|
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
|
|
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
|
|
settings,
|
|
renderableInvoice.currency,
|
|
{ paymentAccountRequired },
|
|
)
|
|
const swishQrDataUrl = await buildSwishQrDataUrl(renderCompany, renderableInvoice)
|
|
const pdfBuffer = await renderToBuffer(
|
|
InvoicePDF({
|
|
invoice: renderableInvoice,
|
|
customer: invoice.customer as Customer,
|
|
items,
|
|
company: renderCompany,
|
|
originalInvoiceNumber: args.originalInvoiceNumber,
|
|
branding,
|
|
swishQrDataUrl,
|
|
}),
|
|
)
|
|
|
|
const filename = invoicePdfFilename({
|
|
companyName: settings.company_name,
|
|
customerName: (invoice.customer as Customer).name,
|
|
invoiceNumber: invoice.invoice_number,
|
|
invoiceId: invoice.id,
|
|
invoiceDate: invoice.invoice_date,
|
|
documentType: invoice.document_type,
|
|
isCreditNote: !!invoice.credited_invoice_id,
|
|
})
|
|
|
|
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
|
await uploadDocument(
|
|
supabase,
|
|
userId,
|
|
companyId,
|
|
{
|
|
name: filename,
|
|
buffer: pdfArrayBuffer,
|
|
type: 'application/pdf',
|
|
},
|
|
{
|
|
upload_source: 'system',
|
|
journal_entry_id: journalEntryId ?? undefined,
|
|
},
|
|
)
|
|
return null
|
|
} catch (err) {
|
|
log.error('failed to archive invoice PDF on mark-sent', err as Error)
|
|
return {
|
|
step: 'pdf_archive',
|
|
reason: 'Fakturans PDF kunde inte arkiveras.',
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Issue a draft invoice without sending an email: assign the F-number, flip
|
|
* the status to 'sent', and (under faktureringsmetoden with inline booking)
|
|
* create and link the revenue verifikat. Exactly the mark-sent semantics for
|
|
* a non-credit-note invoice; used by both POST /api/invoices/[id]/mark-sent
|
|
* and POST /api/invoices/bulk-book so the two can never drift apart.
|
|
*
|
|
* Under kontantmetoden or deferred booking (#967) the invoice is marked sent
|
|
* without a journal entry, matching mark-sent.
|
|
*/
|
|
export async function issueAndBookInvoice(
|
|
opts: IssueAndBookOptions,
|
|
): Promise<IssueAndBookResult> {
|
|
const { supabase, companyId, userId, invoice, settings, log } = opts
|
|
const customLines = opts.customLines ?? null
|
|
const id = invoice.id
|
|
|
|
if (!hasRequiredInvoicePaymentAccount(settings, invoice as Invoice)) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVOICE_SEND_PAYMENT_ACCOUNT_MISSING',
|
|
details: { currency: (invoice as Invoice).currency },
|
|
}
|
|
}
|
|
|
|
if (!hasRequiredSellerVatNumber(settings, invoice as Invoice)) {
|
|
return { ok: false, errorCode: 'INVOICE_SEND_VAT_NUMBER_MISSING' }
|
|
}
|
|
|
|
// 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 { ok: false, errorCode: 'INVOICE_CREATE_NUMBER_ASSIGN_FAILED' }
|
|
}
|
|
|
|
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
|
|
|
// Compare-and-set prevents two concurrent requests from posting two journal
|
|
// entries for the same draft.
|
|
const { data: updatedRows, error: updateError } = await supabase
|
|
.from('invoices')
|
|
.update({ status: 'sent' })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'draft')
|
|
.select('id')
|
|
|
|
if (updateError) {
|
|
log.error('invoice mark-sent status update failed', updateError)
|
|
return { ok: false, errorCode: 'INVOICE_MARK_SENT_STATUS_FAILED' }
|
|
}
|
|
if (!updatedRows || updatedRows.length === 0) {
|
|
return { ok: false, errorCode: 'INVOICE_MARK_SENT_RACE' }
|
|
}
|
|
|
|
// Only create journal entries for real invoices (not proformas or delivery notes)
|
|
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
|
let journalEntryId: string | null = null
|
|
const partialFailures: IssuePartialFailure[] = []
|
|
|
|
// Custom lines only apply where issuance books inline; elsewhere they are
|
|
// deliberately ignored (documented in MarkInvoiceSentSchema). Log it so the
|
|
// mismatch is visible in audit review instead of vanishing silently.
|
|
if (customLines && (!isRealInvoice || !booksInvoicesOnIssue(settings))) {
|
|
log.warn('mark-sent: custom lines ignored (not on accrual book-at-issue path)', {
|
|
invoiceId: id,
|
|
lineCount: customLines.length,
|
|
})
|
|
}
|
|
|
|
if (isRealInvoice && booksInvoicesOnIssue(settings)) {
|
|
// #967: deferred companies fall past this branch (mark sent WITHOUT
|
|
// booking); ekonomi books later via POST /api/invoices/[id]/book, like
|
|
// under kontantmetoden.
|
|
try {
|
|
if (customLines) {
|
|
// Audit trail: distinguish user-edited bookings from generated ones.
|
|
log.info('mark-sent: booking user-edited custom lines', {
|
|
invoiceId: id,
|
|
userId,
|
|
lineCount: customLines.length,
|
|
})
|
|
}
|
|
const journalEntry = customLines
|
|
? await createInvoiceJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice as Invoice,
|
|
entityType,
|
|
invoice.customer?.name,
|
|
{ customLines },
|
|
)
|
|
: await createInvoiceJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice as Invoice,
|
|
entityType,
|
|
invoice.customer?.name,
|
|
)
|
|
if (journalEntry) {
|
|
journalEntryId = journalEntry.id
|
|
|
|
// Periodiserade lines: create schedules + catch-up dissolutions now
|
|
// that the revenue entry exists. Failures are logged, never fatal:
|
|
// the verifikat is committed. Skipped when the user edited the lines:
|
|
// the generated 29xx deferral may no longer exist in what was booked,
|
|
// and a schedule would then dissolve an interim balance that was
|
|
// never credited. User-edited lines book exactly as reviewed.
|
|
if (!customLines) {
|
|
const accrual = await createSchedulesForCustomerInvoice(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice as Invoice,
|
|
(invoice.items as InvoiceItem[] | null) ?? [],
|
|
journalEntry.id,
|
|
entityType,
|
|
)
|
|
if (accrual.failed > 0) {
|
|
log.error('accrual schedule creation failed on mark-sent', {
|
|
failed: accrual.failed,
|
|
})
|
|
partialFailures.push({
|
|
step: 'accrual_schedules',
|
|
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
|
|
})
|
|
}
|
|
}
|
|
|
|
const { error: linkError } = await supabase
|
|
.from('invoices')
|
|
.update({ journal_entry_id: journalEntry.id })
|
|
.eq('id', id)
|
|
if (linkError) {
|
|
// Don't fail the issuance: 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,
|
|
})
|
|
partialFailures.push({
|
|
step: 'journal_link',
|
|
reason: 'Verifikatet skapades men kunde inte kopplas till fakturan.',
|
|
})
|
|
}
|
|
} else {
|
|
partialFailures.push({
|
|
step: 'journal_entry',
|
|
reason: 'Ingen öppen bokföringsperiod hittades för fakturans datum.',
|
|
})
|
|
}
|
|
} catch (err) {
|
|
log.error('failed to create invoice journal entry on mark-sent', err as Error)
|
|
partialFailures.push({
|
|
step: 'journal_entry',
|
|
reason: 'Fakturans verifikat kunde inte skapas.',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fail-closed only when inline booking was supposed to happen: deferred
|
|
// (#967) and cash-method invoices are legitimately unbooked at this point.
|
|
if (isRealInvoice && booksInvoicesOnIssue(settings) && !journalEntryId) {
|
|
const { error: rollbackError } = await supabase
|
|
.from('invoices')
|
|
.update({ status: 'draft' })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'sent')
|
|
.is('journal_entry_id', null)
|
|
if (rollbackError) {
|
|
log.error('failed to restore draft after mark-sent booking failure', rollbackError)
|
|
}
|
|
return { ok: false, errorCode: 'INVOICE_MARK_SENT_BOOK_FAILED' }
|
|
}
|
|
|
|
if (partialFailures.some((failure) => failure.step === 'journal_link')) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVOICE_MARK_SENT_REPAIR_REQUIRED',
|
|
details: { failure_steps: ['journal_link'] },
|
|
}
|
|
}
|
|
|
|
// Render and archive the PDF as underlag so it remains retrievable even if
|
|
// the invoice row is later cancelled. Mirrors the send route.
|
|
if (isRealInvoice) {
|
|
const pdfFailure = await archiveIssuedInvoicePdf({
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoice,
|
|
settings,
|
|
journalEntryId,
|
|
log,
|
|
})
|
|
if (pdfFailure) partialFailures.push(pdfFailure)
|
|
}
|
|
|
|
try {
|
|
await recordManualInvoiceDelivery({
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
invoiceId: id,
|
|
})
|
|
} catch (err) {
|
|
log.error('failed to record manual invoice delivery', err as Error)
|
|
partialFailures.push({
|
|
step: 'delivery_history',
|
|
reason: 'Utskicket kunde inte sparas i fakturans historik.',
|
|
})
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'invoice.sent',
|
|
payload: { invoice: { ...(invoice as Invoice), status: 'sent' }, companyId, userId },
|
|
})
|
|
|
|
return { ok: true, journalEntryId, partialFailures }
|
|
}
|