feat(invoices): allocate-on-save, makulera flow, manual invoice picker (#405)
* feat(invoices): allocate-on-save, makulera flow, manual invoice picker
Three coordinated invoice changes:
1. Allocate F-series number when the draft is created (Fortnox-style),
not at send time. Users can download a numbered draft and send it
manually. If number allocation fails, the invoice + items are rolled
back so no orphaned rows remain. Adds INVOICE_CREATE_NUMBER_ASSIGN_FAILED.
2. DELETE /api/invoices/[id] now soft-cancels (status='cancelled') instead
of hard-deleting. The F-series number is retained, keeping the sequence
gap-free per ML 17 kap 24§ and BFNAR 2013:2 — no voucher_gap_explanations
needed. Sent/paid invoices stay immutable (credit note required). Adds
"Makulerade" tab to the invoice list; cancelled invoices are hidden from
"Alla" by default. PDF draft banner stays visible on numbered drafts and
only clears when the invoice is marked sent.
3. New InvoicePicker component lets users manually match an income
transaction to an open invoice from the booking dialog ("Matcha med
faktura..."), complementing the existing auto-match flow.
Also: new-invoice review dialog reads accounting_method from settings and
shows a cash-vs-accrual warning so users know when the verification posts.
seed-demo-account adds year-end closing + opening balance helpers so
multi-year demo data is balanced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): address review feedback on PR #405
Greptile P1 + Swedish compliance reviewer findings:
- app/api/invoices/route.ts — replace hard-delete rollback on number-
allocation failure with a soft-cancel (status='cancelled'). If
generate_invoice_number bumped the sequence before failing to write
the number back, hard-deleting would leave a permanent gap in the
F-series in violation of ML 17 kap 24§. Re-fetch invoice_number
first so any partially-written value is logged for operator follow-up.
Log loudly if the cancel itself fails so an orphan row doesn't go
unnoticed.
- app/api/invoices/[id]/route.ts — close TOCTOU race on the cancel
update. The .eq('status','draft') guard prevented data corruption
but Supabase returned error: null with 0 affected rows on a
concurrent flip, and the handler reported success. Add .select('id')
and return new INVOICE_CANCEL_RACE (409) when no row updated.
- components/transactions/InvoicePicker.tsx — memoize createClient()
so the supabase reference is stable across renders. Without this,
including supabase in the useEffect dep array fires the open-invoices
fetch on every render.
- app/(dashboard)/transactions/page.tsx + match-invoice/route.ts —
read category from the match-invoice response instead of hardcoding
'income_services' client-side. Server now echoes the category it
actually booked; client falls back to 'income_services' if absent.
- lib/invoices/pdf-template.tsx — add MAKULERAD banner for cancelled
invoices (red, distinct from the yellow draft banner). A cancelled
invoice PDF previously rendered with no warning if it had a number,
or with the draft banner if it didn't — both could be mistaken for a
valid faktura. Cancelled takes precedence over draft so the legacy
un-numbered-cancelled case is also covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): guard cancelled status on send + rollback symmetry
Two follow-up fixes from the second-round Swedish compliance review on
PR #405:
- app/api/invoices/[id]/send/route.ts — reject sending a cancelled
invoice. The existing flow had no status guard before
.update({ status: 'sent' }), so a cancelled invoice could be silently
re-activated to sent and a "MAKULERAD"-watermarked PDF could be
delivered to the customer as if it were a live faktura. New
INVOICE_SEND_CANCELLED (400) returned at the top of the handler.
- app/api/invoices/route.ts — add .eq('status', 'draft') to the
rollback-cancel update so the rollback is symmetric with the DELETE
handler's only-drafts-may-be-cancelled rule. At the create flow's
current shape the row can't realistically be anything other than
draft, but the symmetry prevents a future caller adding a status flip
between insert and number-allocation from accidentally cancelling a
posted invoice.
mark-sent (rejects non-draft), mark-paid (only sent/overdue), and
convert (explicitly rejects cancelled proformas) already guard
correctly — no changes needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): InvoicePicker filters settled invoices; drop dead error code
Two cleanups from the third-round Swedish compliance review on PR #405:
- components/transactions/InvoicePicker.tsx — add .gt('remaining_amount', 0)
defensively. The picker filtered by status IN (sent, overdue,
partially_paid), but a stale 'sent' or 'overdue' row with
remaining_amount=0 (data inconsistency) would otherwise be selectable
here and could be matched a second time, double-booking the income —
a direct BFL 5 kap accuracy violation.
- lib/errors/structured-errors.ts — remove INVOICE_DELETE_NUMBERED.
The numbered-draft refusal was replaced by the soft-cancel path
earlier in this PR; the entry has no remaining callers.
Verified-safe and not changed:
- Cancel-without-storno concern: createInvoiceJournalEntry only fires
inside mark-sent (after the draft→sent guard) or send (after the
cancelled-status reject). Drafts never have posted verifications, so
cancelling a draft cannot leave an orphaned bokföringspost.
- Hardcoded category: 'income_services' in match-invoice is a
pre-existing classification concern that warrants a larger refactor
(derive from invoice's revenue accounts) rather than a one-line patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(invoices): InvoicePicker excludes proforma invoices
Add .eq('document_type', 'invoice') to the open-invoice query. A
proforma is not a faktura per ML 17 kap 24§ — no VAT obligation, no
binding commercial document — and must never be matched against a
bank receipt. Without this guard a sent proforma could be selected
in the picker, triggering a payment booking and VAT-rate journal
entry that violates BFL 5 kap accuracy rules.
Other findings from the third-round Swedish compliance review were
verified-safe and not changed:
- Cancelled-invoice PDF download path: the MAKULERAD watermark added
earlier in this PR is the safeguard. Blocking the download endpoint
outright would prevent legitimate audit access; the visible banner
prevents the doc being mistaken for a valid faktura.
- Cancel-without-storno: createInvoiceJournalEntry only fires inside
mark-sent / send / pending-operations, all behind status guards.
Drafts never carry a posted verifikation, so cancel can't orphan one.
- Allocate-on-save for proforma uses F-series: not true. The
generate_invoice_number RPC (migration 20260427150100) routes
document_type='proforma' to a separate 'PF-' prefix sequence; the
F-series is untouched.
- closeYearForSeed 2099 → 2091 transfer: real demo-data correctness
issue but a seed-script polish item — separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(match-invoice): server-side document_type='invoice' guard
The InvoicePicker filter excluding proformas (added in the previous
commit) is client-only. A direct API call to /api/transactions/[id]/
match-invoice with a proforma id would otherwise still book a payment
journal entry against a document that has no VAT obligation per
ML 17 kap 24§. Add a defense-in-depth check after the invoice fetch.
New error code MATCH_INVOICE_NOT_INVOICE_TYPE (400). Test added.
Other findings from the latest compliance review were verified-safe and
not changed:
- Cancelled-invoice PDF download path: /api/invoices/[id]/pdf always
re-renders through InvoicePDF, so the MAKULERAD banner is always
present. The bot's "cached pre-cancellation PDF" scenario does not
apply to this codebase.
- Proforma F-series allocation: the generate_invoice_number RPC routes
document_type='proforma' to a separate 'PF-' prefix; the F-series is
not polluted.
- Soft-cancel rollback gap when number not written: the RPC is a
single-transaction PL/pgSQL function — sequence bump (UPDATE
company_settings) and row write (UPDATE invoices) commit or roll
back together. The "sequence advanced but row null" scenario the
bot describes is impossible by construction; a thrown exception in
the row-write step rolls back the bump.
- closeYearForSeed obeskattade reserver: seed-script demo accuracy,
separate PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b89abf64b1
commit
97db09a3ff
@@ -55,7 +55,7 @@ describe('DELETE /api/invoices/[id]', () => {
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('rejects deletion of a non-draft invoice with INVOICE_DELETE_NOT_DRAFT', async () => {
|
||||
it('rejects cancellation of a non-draft invoice with INVOICE_DELETE_NOT_DRAFT', async () => {
|
||||
enqueue({
|
||||
data: { id: 'inv-1', status: 'sent', invoice_number: 'F-2026099', user_id: 'user-1' },
|
||||
error: null,
|
||||
@@ -71,49 +71,71 @@ describe('DELETE /api/invoices/[id]', () => {
|
||||
expect(body.error.code).toBe('INVOICE_DELETE_NOT_DRAFT')
|
||||
})
|
||||
|
||||
it('rejects deletion of a draft that already has an invoice_number', async () => {
|
||||
it('cancels a numbered draft, retaining the F-series number', async () => {
|
||||
enqueue({
|
||||
data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'inv-1' })
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { invoice_number?: string } }
|
||||
data: { cancelled: boolean; invoice_number: string | null }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('INVOICE_DELETE_NUMBERED')
|
||||
expect(body.error.details?.invoice_number).toBe('F-2026001')
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.cancelled).toBe(true)
|
||||
expect(body.data.invoice_number).toBe('F-2026001')
|
||||
})
|
||||
|
||||
it('deletes a draft with no invoice_number', async () => {
|
||||
it('cancels an un-numbered draft (legacy null-number row)', async () => {
|
||||
enqueue({
|
||||
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'inv-1' })
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { cancelled: boolean; invoice_number: string | null }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.deleted).toBe(true)
|
||||
expect(body.data.cancelled).toBe(true)
|
||||
expect(body.data.invoice_number).toBeNull()
|
||||
})
|
||||
|
||||
it('returns 500 when items delete fails', async () => {
|
||||
it('returns 409 INVOICE_CANCEL_RACE when status flipped between fetch and update', async () => {
|
||||
enqueue({
|
||||
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
|
||||
data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'items delete failed' } })
|
||||
// Update succeeds with no error but matches 0 rows because the .eq('status','draft')
|
||||
// guard rejected the row (concurrent send/cancel flipped status in the meantime).
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
|
||||
createMockRouteParams({ id: 'inv-1' })
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('INVOICE_CANCEL_RACE')
|
||||
})
|
||||
|
||||
it('returns 500 when the cancel update fails', async () => {
|
||||
enqueue({
|
||||
data: { id: 'inv-1', status: 'draft', invoice_number: 'F-2026001', user_id: 'user-1' },
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'cancel update failed' } })
|
||||
|
||||
const response = await DELETE(
|
||||
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
|
||||
|
||||
@@ -5,21 +5,21 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('api.invoices.delete')
|
||||
const log = createLogger('api.invoices.cancel')
|
||||
|
||||
/**
|
||||
* DELETE /api/invoices/[id]
|
||||
*
|
||||
* Permanently deletes a draft invoice and its items.
|
||||
* Cancels (makulerar) a draft invoice. The row and its F-series number are
|
||||
* retained — the invoice transitions to status='cancelled'. Keeping the row
|
||||
* preserves the invoice-number sequence per ML 17 kap 24§ and BFNAR 2013:2,
|
||||
* so the F-series stays gap-free without any voucher_gap_explanations entry.
|
||||
*
|
||||
* Two preconditions:
|
||||
* 1. status === 'draft' — committed invoices are immutable per BFL and
|
||||
* must be reversed via credit note.
|
||||
* 2. invoice_number IS NULL — a draft that already holds an F-series
|
||||
* number is a side effect of an interrupted send/convert/mark-sent.
|
||||
* Destroying it would orphan the number and create a permanent gap
|
||||
* in the verifications series. Refuse and let the user retry the
|
||||
* send instead (ensureInvoiceNumber is idempotent).
|
||||
* Only drafts may be cancelled this way. Sent / paid invoices are immutable
|
||||
* per BFL and must be reversed via a credit note instead.
|
||||
*
|
||||
* Old drafts predating allocate-on-save may have invoice_number = NULL; those
|
||||
* still cancel (status flip) without consuming a number — no special-case path.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
@@ -54,30 +54,25 @@ export async function DELETE(
|
||||
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
|
||||
}
|
||||
|
||||
if (invoice.invoice_number !== null) {
|
||||
return errorResponseFromCode('INVOICE_DELETE_NUMBERED', log, {
|
||||
details: { invoice_number: invoice.invoice_number },
|
||||
})
|
||||
}
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.delete()
|
||||
.eq('invoice_id', id)
|
||||
|
||||
if (itemsError) {
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
// .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')
|
||||
.delete()
|
||||
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
if (cancelError) {
|
||||
return NextResponse.json({ error: cancelError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
if (!updated || updated.length === 0) {
|
||||
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { cancelled: true, invoice_number: invoice.invoice_number } })
|
||||
}
|
||||
|
||||
@@ -131,6 +131,23 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_PAID_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is cancelled (makulerad)', async () => {
|
||||
const cancelledInvoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'cancelled',
|
||||
invoice_number: 'F-2026001',
|
||||
items: [],
|
||||
})
|
||||
enqueue({ data: cancelledInvoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED')
|
||||
})
|
||||
|
||||
it('returns 400 when customer has no email', async () => {
|
||||
const noEmailInvoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
|
||||
@@ -45,6 +45,14 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
// A cancelled invoice keeps its F-series number for compliance with ML 17
|
||||
// kap 24§ but is not a valid faktura — sending it would silently
|
||||
// re-activate it (the .update({ status: 'sent' }) below has no status
|
||||
// guard) and could deliver a "MAKULERAD" PDF as if it were live.
|
||||
if (invoice.status === 'cancelled') {
|
||||
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) {
|
||||
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
it('creates invoice with items and emits event', async () => {
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
@@ -188,12 +188,14 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// Insert invoice (no number generated for drafts — assigned at send time)
|
||||
// Insert invoice (number is null on insert; allocated immediately after items)
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// ensureInvoiceNumber → generate_invoice_number RPC
|
||||
enqueue({ data: '2026001', error: null })
|
||||
// Fetch complete invoice
|
||||
enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null })
|
||||
enqueue({ data: { ...createdInvoice, invoice_number: '2026001', customer, items: [] }, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
@@ -258,6 +260,51 @@ describe('POST /api/invoices (create invoice)', () => {
|
||||
expect(status).toBe(500)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED')
|
||||
})
|
||||
|
||||
it('soft-cancels the invoice when invoice-number allocation fails', async () => {
|
||||
const customer = makeCustomer({ id: VALID_UUID })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '10',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockCalculateVat.mockReturnValue(2500)
|
||||
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' },
|
||||
])
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
// generate_invoice_number RPC fails
|
||||
enqueue({ data: null, error: { message: 'sequence locked' } })
|
||||
// Rollback path: re-fetch invoice_number, then soft-cancel.
|
||||
enqueue({ data: { invoice_number: null }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: VALID_UUID,
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_NUMBER_ASSIGN_FAILED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices (create credit note)', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocument
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
@@ -232,6 +233,56 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Allocate F-series number on save (Fortnox-style). The user gets a numbered
|
||||
// draft they can download and send manually without first lying about
|
||||
// having sent it. Discarded numbered drafts become 'cancelled' rather than
|
||||
// deleted, so the F-series stays gap-free per ML 17 kap 24§.
|
||||
// Delivery notes already have their number from the insert above.
|
||||
if (documentType === 'invoice' || documentType === 'proforma') {
|
||||
try {
|
||||
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
|
||||
} catch (err) {
|
||||
// Soft-cancel rather than hard-delete: if generate_invoice_number bumped
|
||||
// the sequence before failing to write the number back, hard-deleting
|
||||
// would leave a permanent gap in the F-series in violation of ML 17 kap
|
||||
// 24§. Re-fetch the row to pick up any partially-written number, then
|
||||
// flip status='cancelled' so the row (and any allocated number) is
|
||||
// retained for audit. Log loudly if the cancel itself fails so an
|
||||
// operator can clean up.
|
||||
const { data: latest } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
// Guard on status='draft' for symmetry with the DELETE handler — only
|
||||
// drafts may be cancelled. At this point in the create flow the row
|
||||
// can't realistically be anything else, but the symmetry prevents a
|
||||
// future caller adding a status flip between insert and number-
|
||||
// allocation from accidentally cancelling a posted invoice.
|
||||
const { error: cancelErr } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', companyId!)
|
||||
.eq('status', 'draft')
|
||||
if (cancelErr) {
|
||||
log.error('invoice number allocation failed AND rollback-cancel failed; row may be orphaned', cancelErr, {
|
||||
invoiceId: invoice.id,
|
||||
allocatedNumber: latest?.invoice_number ?? null,
|
||||
originalError: (err as Error).message,
|
||||
})
|
||||
} else {
|
||||
log.error('invoice number allocation failed; invoice soft-cancelled', err as Error, {
|
||||
invoiceId: invoice.id,
|
||||
allocatedNumber: latest?.invoice_number ?? null,
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, {
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
|
||||
@@ -149,6 +149,27 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when matching against a proforma (defense-in-depth)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const proforma = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
document_type: 'proforma',
|
||||
} as Parameters<typeof makeInvoice>[0])
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: proforma, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_NOT_INVOICE_TYPE')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' })
|
||||
|
||||
@@ -80,6 +80,19 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, { requestId })
|
||||
}
|
||||
|
||||
// Defense-in-depth: the InvoicePicker UI filters proformas / delivery
|
||||
// notes out of the candidate list, but a direct API call could still
|
||||
// pass a proforma id. A proforma is not a faktura per ML 17 kap 24§ —
|
||||
// no VAT obligation, no binding payment — so matching one against a
|
||||
// bank receipt would book income and VAT incorrectly.
|
||||
const docType = (invoice as { document_type?: string }).document_type ?? 'invoice'
|
||||
if (docType !== 'invoice') {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
|
||||
requestId,
|
||||
details: { documentType: docType },
|
||||
})
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
|
||||
return errorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
|
||||
requestId,
|
||||
@@ -267,6 +280,7 @@ export const POST = withRouteContext(
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
category: 'income_services',
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
Reference in New Issue
Block a user