* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner Why the problem occurred: the draft marking was a boxed yellow banner in the page's top margin. It stayed out of the flow (#2369) but still read as UI chrome pasted on a document, and carried a two-line legal sentence that nobody reads on a preview. What was removed: the banner block, its three styles and the four legal sentences (sv+en). The draft state is now one word, bold, rotated -35deg at 14% opacity, centred on every page, the way a stamp marks paper. The download dialog (#2399) already explains why a draft is not a valid invoice before the file exists, so the PDF does not repeat it. Why this shape: rotation and opacity sit on a padded wrapper View so the word turns about its own centre and the Text keeps a plain type style. The overlay is absolutely positioned over the page box and `fixed`, so the document underneath previews pixel-identical to the final print; a test asserts the first row sits at the same y as on a sent invoice. BETALD and MAKULERAD banners are unchanged (separate concern). Fixes #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(invoices): darken the draft watermark so it survives a greyscale print Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on white, which a monochrome print or greyscale scan drops, and a numbered draft otherwise prints the FAKTURA title, its number and an OCR like an issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85% so neither extreme can creep back in. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): paint the draft watermark last so opaque boxes cannot cover it Skeptic refutation (correctness and regression, independently): the overlay was the first child of the Page. react-pdf paints children in document order and `fixed` does not hoist, so the customer box and the full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the word. On a two-page draft the last page, the one with totals, bankgiro and OCR, lost the word entirely. The overlay is now the last child of the Page, behind a single isDraftMarked flag that also keeps the cancelled > draft > paid banner precedence. A new test inflates the rendered PDF content streams and asserts the UTKAST glyph run comes after the last rectangle fill on every page, so the element tree alone can no longer pass while the paint order is wrong. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(invoices): note the English DRAFT label in the download-decision comment CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an English document says DRAFT. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
import { renderToBuffer } from '@react-pdf/renderer'
|
|
import { resolveCompanyEntityType } from '@/lib/company/entity-type'
|
|
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 { snapshotInvoicePayee } from '@/lib/invoices/invoice-payee'
|
|
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".
|
|
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, payee: renderableInvoice.payment_details ?? null },
|
|
)
|
|
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
|
|
|
|
// An invoice that chose a bank account freezes that account's payee now,
|
|
// from the account as it is at issue; a chosen account that can no longer
|
|
// be used blocks issue instead of silently printing the company default.
|
|
const payeeSnapshot = await snapshotInvoicePayee(supabase, companyId, invoice as Invoice)
|
|
if (!payeeSnapshot.ok) {
|
|
return { ok: false, errorCode: payeeSnapshot.code, details: payeeSnapshot.details }
|
|
}
|
|
;(invoice as Invoice).payment_details = payeeSnapshot.payee
|
|
|
|
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 = await resolveCompanyEntityType(supabase, companyId, settings.entity_type)
|
|
|
|
// 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 }
|
|
}
|