Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
94 lines
3.9 KiB
TypeScript
94 lines
3.9 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import type { Invoice } from '@/types'
|
|
|
|
ensureInitialized() // Module-level: loads extensions so invoice.created handlers are wired.
|
|
|
|
/**
|
|
* POST /api/invoices/[id]/finalize: "Granska och skapa".
|
|
*
|
|
* Turns an unnumbered draft (saved via "Spara som utkast") into a real, issued
|
|
* invoice: allocates the F-series number and emits invoice.created (which drives
|
|
* webhooks + the audit log). After this the invoice behaves exactly like one
|
|
* created directly: it can be sent or cancelled (makulerad), but no longer
|
|
* hard-deleted, because the number now belongs to the gap-free series
|
|
* (ML 17 kap 24§).
|
|
*
|
|
* Only an unnumbered draft (status='draft', invoice_number IS NULL,
|
|
* document_type='invoice') may be finalized. Numbering is idempotent inside the
|
|
* generate_invoice_number RPC, but the explicit guard keeps the contract clear.
|
|
*/
|
|
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'invoice.finalize',
|
|
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const { data: invoice, error: fetchError } = await supabase
|
|
.from('invoices')
|
|
.select('id, status, invoice_number, document_type, is_self_billed')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !invoice) {
|
|
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
|
|
}
|
|
|
|
// Only an unnumbered draft can be finalized. Numbered drafts are already
|
|
// issued (cancel via makulering instead); sent/paid invoices are immutable.
|
|
// A null document_type means a plain invoice (older rows / default).
|
|
// Self-billed drafts are counterparty documents (självfakturering) and must
|
|
// not be allocated an F-series number through this flow even if a direct
|
|
// API call left one unnumbered.
|
|
const docType = invoice.document_type ?? 'invoice'
|
|
if (
|
|
invoice.status !== 'draft' ||
|
|
invoice.invoice_number ||
|
|
docType !== 'invoice' ||
|
|
invoice.is_self_billed
|
|
) {
|
|
return errorResponseFromCode('INVOICE_FINALIZE_NOT_DRAFT', log, { requestId })
|
|
}
|
|
|
|
try {
|
|
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
|
|
} catch (err) {
|
|
log.error('failed to assign invoice number on finalize', err as Error, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
|
|
}
|
|
|
|
const { data: completeInvoice, error: refetchError } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
// The number was already allocated, so the invoice is finalized in the DB,
|
|
// but if the re-read fails we cannot emit invoice.created with a complete
|
|
// payload, which would silently drop the audit-log entry, webhooks, and any
|
|
// extension wired to the event. Surface it as a 500 rather than returning
|
|
// 200 with a null body and a hollow success toast. A reload shows the
|
|
// (correctly numbered) invoice; the failure is now visible in monitoring.
|
|
if (refetchError || !completeInvoice) {
|
|
log.error(
|
|
'finalize: number allocated but invoice re-read failed; invoice.created not emitted',
|
|
refetchError as Error,
|
|
{ invoiceId: id },
|
|
)
|
|
return errorResponseFromCode('INVOICE_FINALIZE_INCOMPLETE', log, { requestId })
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'invoice.created',
|
|
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
|
|
})
|
|
|
|
return NextResponse.json({ data: completeInvoice })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|