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:
Jakob Wennberg
2026-05-06 22:49:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent b89abf64b1
commit 97db09a3ff
16 changed files with 747 additions and 116 deletions
+36 -14
View File
@@ -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' }),
+25 -30
View File
@@ -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',
+8
View File
@@ -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, {
+50 -3
View File
@@ -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)', () => {
+51
View File
@@ -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 },