* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { UpdateInvoiceSchema } from '@/lib/api/schemas'
|
|
import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write'
|
|
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
|
import type { InvoiceDocumentType } from '@/types'
|
|
|
|
ensureInitialized() // Module-level: wires the audit-log handler for invoice.draft_deleted.
|
|
|
|
/**
|
|
* DELETE /api/invoices/[id]
|
|
*
|
|
* Removes a draft invoice. Behaviour depends on whether a number was issued:
|
|
*
|
|
* - Unnumbered draft (saved via "Spara som utkast", never finalized): hard
|
|
* deleted. No F-series number was consumed, so there is no gap to document
|
|
* (ML 17 kap 24§). invoice_items cascade via the FK.
|
|
* - Numbered draft (created directly, or finalized via "Granska och skapa"):
|
|
* makulerad: the row and its number are retained and status flips to
|
|
* 'cancelled', keeping the F-series gap-free per ML 17 kap 24§ / BFNAR 2013:2.
|
|
*
|
|
* Only drafts may be removed either way. Sent / paid invoices are immutable per
|
|
* BFL and must be reversed via a credit note instead.
|
|
*/
|
|
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'invoice.delete',
|
|
async (_request, { user, supabase, companyId, log, requestId }, { params }) => {
|
|
const { id } = await params
|
|
const opLog = log.child({ invoiceId: id })
|
|
|
|
const { data: invoice, error: fetchError } = await supabase
|
|
.from('invoices')
|
|
.select('id, status, invoice_number, user_id, credited_invoice_id, journal_entry_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !invoice) {
|
|
return errorResponseFromCode('INVOICE_NOT_FOUND', opLog, { requestId })
|
|
}
|
|
|
|
if (invoice.status !== 'draft') {
|
|
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', opLog, { requestId })
|
|
}
|
|
|
|
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
|
|
// yet issued invoices (no F-series number was consumed) so they can be hard
|
|
// deleted with no gap in the sequence (ML 17 kap 24§). invoice_items cascade
|
|
// via the FK (ON DELETE CASCADE); an un-finalized draft has no journal entry
|
|
// or linked document. The status='draft' + invoice_number IS NULL guard makes
|
|
// the delete a no-op if the row was finalized (numbered) concurrently.
|
|
if (!invoice.invoice_number) {
|
|
const { data: removed, error: removeError } = await supabase
|
|
.from('invoices')
|
|
.delete()
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'draft')
|
|
.is('invoice_number', null)
|
|
.select('id')
|
|
|
|
if (removeError) {
|
|
opLog.error('invoice draft delete failed', removeError)
|
|
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
|
}
|
|
|
|
if (!removed || removed.length === 0) {
|
|
// Finalized between fetch and delete: refuse rather than fall through to
|
|
// makulering of a now-issued invoice.
|
|
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
|
}
|
|
|
|
// The row is gone, so there's no journal trace of the removal. Emit an
|
|
// audit event carrying the identifiers so the event log records who deleted
|
|
// which draft and when: the makulering path leaves a journal/status trail,
|
|
// a hard delete otherwise leaves none.
|
|
await eventBus.emit({
|
|
type: 'invoice.draft_deleted',
|
|
payload: { invoiceId: id, companyId, userId: user.id },
|
|
})
|
|
|
|
return NextResponse.json({ data: { deleted: true } })
|
|
}
|
|
|
|
// Numbered draft: retain the row and its number, flip to 'cancelled'
|
|
// (makulering) so the F-series stays gap-free.
|
|
// .select() returns the affected rows so we can detect a TOCTOU race where
|
|
// the status flipped between the fetch above and this update. With only the
|
|
// .eq('status','draft') guard, a 0-row update returns success and the user
|
|
// would see "Makulerad" while the invoice is still in its previous state.
|
|
const { data: updated, error: cancelError } = await supabase
|
|
.from('invoices')
|
|
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'draft')
|
|
.select('id')
|
|
|
|
if (cancelError) {
|
|
opLog.error('invoice cancellation failed', cancelError)
|
|
return errorResponseFromCode('INVOICE_DELETE_FAILED', opLog, { requestId })
|
|
}
|
|
|
|
if (!updated || updated.length === 0) {
|
|
return errorResponseFromCode('INVOICE_CANCEL_RACE', opLog, { requestId })
|
|
}
|
|
|
|
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
/**
|
|
* PATCH /api/invoices/[id]
|
|
*
|
|
* Edit a DRAFT invoice (or proforma / delivery note) in place: header fields
|
|
* AND line items. Only drafts are editable: a journal entry (verifikat) is
|
|
* created when an invoice is sent (mark-sent / send) or, for kontantmetoden, at
|
|
* payment, so a draft has no committed entry and BFL immutability (guard rail #1)
|
|
* does not yet apply. Sent / paid / cancelled / credited invoices are immutable
|
|
* and must be reversed via a credit note instead.
|
|
*
|
|
* The invoice's number and status are preserved: editing never (re)allocates a
|
|
* number nor changes lifecycle state, and never emits invoice.created (numbered
|
|
* drafts already emitted it at create; unnumbered ones emit on finalize). The
|
|
* validation + computation is shared with POST /api/invoices via
|
|
* buildInvoiceWriteData so VAT rules, ROT/RUT, accruals and totals stay identical.
|
|
*/
|
|
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'invoice.update',
|
|
async (request, { supabase, companyId, log: ctxLog, requestId }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const validation = await validateBody(request, UpdateInvoiceSchema, {
|
|
log: ctxLog,
|
|
operation: 'invoice.update',
|
|
})
|
|
if (!validation.success) return validation.response
|
|
const input = validation.data
|
|
const documentType: InvoiceDocumentType = input.document_type || 'invoice'
|
|
|
|
// Fetch the target. Only drafts (not sent, no committed verifikat, not a
|
|
// received self-billing document) may be edited.
|
|
const { data: existing, error: fetchError } = await supabase
|
|
.from('invoices')
|
|
.select('id, status, invoice_number, journal_entry_id, is_self_billed, credited_invoice_id')
|
|
.eq('id', id)
|
|
.eq('company_id', companyId!)
|
|
.single()
|
|
|
|
if (fetchError || !existing) {
|
|
return errorResponseFromCode('INVOICE_NOT_FOUND', ctxLog, { requestId })
|
|
}
|
|
|
|
// journal_entry_id is belt-and-suspenders: a draft shouldn't carry one,
|
|
// but if some flow ever booked it, refuse the edit (the entry is immutable).
|
|
// Shared predicate (lib/invoices/is-editable-draft): the single source of
|
|
// truth the detail and edit pages also gate on, so the rule can't drift.
|
|
if (!isEditableInvoiceDraft(existing)) {
|
|
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
|
|
}
|
|
|
|
// Resolve the (possibly changed) customer.
|
|
const { data: customer, error: customerError } = await supabase
|
|
.from('customers')
|
|
.select('*')
|
|
.eq('id', input.customer_id)
|
|
.eq('company_id', companyId!)
|
|
.single()
|
|
|
|
if (customerError || !customer) {
|
|
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctxLog, {
|
|
requestId,
|
|
details: { customerId: input.customer_id },
|
|
})
|
|
}
|
|
|
|
const build = await buildInvoiceWriteData({
|
|
supabase,
|
|
companyId: companyId!,
|
|
customer,
|
|
documentType,
|
|
input,
|
|
})
|
|
if (!build.ok) {
|
|
if ('dbError' in build) {
|
|
ctxLog.error('invoice write build failed on a DB lookup', build.dbError as Error)
|
|
return errorResponse(build.dbError, ctxLog, { requestId })
|
|
}
|
|
return errorResponseFromCode(build.code, ctxLog, { requestId, details: build.details })
|
|
}
|
|
|
|
// Update the draft row. invoice_number + status are intentionally NOT in
|
|
// build.invoiceFields, so they are preserved. The .eq('status','draft')
|
|
// guard turns a concurrent send/finalize into a 0-row update (race), rather
|
|
// than silently rewriting a now-issued invoice.
|
|
// Öresavrundning is display-only and optional in the update body. Persist
|
|
// it when the editor sent a value; otherwise strip it so a partial update
|
|
// can't reset a draft's stored flag (build defaults an absent flag to null).
|
|
const updateFields =
|
|
input.ore_rounding === undefined
|
|
? (() => {
|
|
const { ore_rounding: _oreRounding, ...rest } = build.invoiceFields
|
|
return rest
|
|
})()
|
|
: build.invoiceFields
|
|
const { data: updated, error: updateError } = await supabase
|
|
.from('invoices')
|
|
.update({ ...updateFields, updated_at: new Date().toISOString() })
|
|
.eq('id', id)
|
|
.eq('company_id', companyId!)
|
|
.eq('status', 'draft')
|
|
.select('id')
|
|
|
|
if (updateError) {
|
|
ctxLog.error('invoice update failed', updateError, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', ctxLog, {
|
|
requestId,
|
|
details: { pgCode: updateError.code, pgMessage: updateError.message },
|
|
})
|
|
}
|
|
if (!updated || updated.length === 0) {
|
|
return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId })
|
|
}
|
|
|
|
// Replace line items wholesale. A draft has no journal entry or linked docs,
|
|
// so delete + reinsert is safe and lets the user add / remove / reorder rows
|
|
// freely. invoice_items cascade nothing else.
|
|
const { error: deleteItemsError } = await supabase
|
|
.from('invoice_items')
|
|
.delete()
|
|
.eq('invoice_id', id)
|
|
|
|
if (deleteItemsError) {
|
|
ctxLog.error('invoice items delete failed on update', deleteItemsError, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
|
|
requestId,
|
|
details: { pgCode: deleteItemsError.code, pgMessage: deleteItemsError.message },
|
|
})
|
|
}
|
|
|
|
const itemsToInsert = build.items.map((item) => ({ ...item, invoice_id: id }))
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(itemsToInsert)
|
|
|
|
if (itemsError) {
|
|
ctxLog.error('invoice items insert failed on update', itemsError, { invoiceId: id })
|
|
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, {
|
|
requestId,
|
|
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
|
})
|
|
}
|
|
|
|
const { data: completeInvoice } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
return NextResponse.json({ data: completeInvoice })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|