* 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>
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
|
|
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
|
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'
|
|
|
|
ensureInitialized()
|
|
|
|
export const GET = withRouteContext(
|
|
'invoice.list',
|
|
async (request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const { searchParams } = new URL(request.url)
|
|
const status = searchParams.get('status')
|
|
const limit = parseInt(searchParams.get('limit') || '50')
|
|
const offset = parseInt(searchParams.get('offset') || '0')
|
|
|
|
let query = supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*)', { count: 'exact' })
|
|
.eq('company_id', companyId)
|
|
.order('invoice_date', { ascending: false })
|
|
.range(offset, offset + limit - 1)
|
|
|
|
if (status) {
|
|
query = query.eq('status', status)
|
|
}
|
|
|
|
const { data, error, count } = await query
|
|
|
|
if (error) {
|
|
log.error('failed to list invoices', error)
|
|
return errorResponse(error, log, { requestId })
|
|
}
|
|
|
|
return NextResponse.json({ data, count })
|
|
},
|
|
)
|
|
|
|
export const POST = withRouteContext(
|
|
'invoice.create',
|
|
async (request, ctx) => {
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
let rawBody: unknown
|
|
try {
|
|
rawBody = await request.json()
|
|
} catch {
|
|
log.warn('invalid json body', { kind: 'json' })
|
|
return NextResponse.json(
|
|
{ error: 'Invalid JSON in request body', type: 'validation_error' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
if (typeof rawBody === 'object' && rawBody !== null && 'credited_invoice_id' in rawBody) {
|
|
const parsed = CreateCreditNoteSchema.safeParse(rawBody)
|
|
if (!parsed.success) {
|
|
log.warn('credit note validation failed', {
|
|
issueCount: parsed.error.issues.length,
|
|
})
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Validation failed',
|
|
type: 'validation_error',
|
|
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
return createCreditNote(supabase, companyId!, user.id, parsed.data, log, requestId)
|
|
}
|
|
|
|
const parsed = CreateInvoiceSchema.safeParse(rawBody)
|
|
if (!parsed.success) {
|
|
log.warn('invoice validation failed', { issueCount: parsed.error.issues.length })
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Validation failed',
|
|
type: 'validation_error',
|
|
errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })),
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
const invoiceInput = parsed.data
|
|
const documentType: InvoiceDocumentType = invoiceInput.document_type || 'invoice'
|
|
|
|
const { data: customer, error: customerError } = await supabase
|
|
.from('customers')
|
|
.select('*')
|
|
.eq('id', invoiceInput.customer_id)
|
|
.eq('company_id', companyId!)
|
|
.single()
|
|
|
|
if (customerError || !customer) {
|
|
return errorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', log, {
|
|
requestId,
|
|
details: { customerId: invoiceInput.customer_id },
|
|
})
|
|
}
|
|
|
|
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
|
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
|
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
|
|
|
const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
|
|
|
|
let vatAmount = 0
|
|
if (documentType !== 'delivery_note') {
|
|
for (const item of invoiceInput.items) {
|
|
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
|
if (!allowedRates.has(itemRate)) {
|
|
return errorResponseFromCode('INVOICE_CREATE_VAT_RULE_VIOLATION', log, {
|
|
requestId,
|
|
details: {
|
|
attemptedRate: itemRate,
|
|
allowedRates: Array.from(allowedRates),
|
|
customerType: customer.customer_type,
|
|
},
|
|
})
|
|
}
|
|
const lineTotal = item.quantity * item.unit_price
|
|
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
|
}
|
|
}
|
|
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
|
|
|
|
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
|
|
const isMixedRate = uniqueRates.size > 1
|
|
|
|
let exchangeRate: number | null = null
|
|
let exchangeRateDate: string | null = null
|
|
let subtotalSek: number | null = null
|
|
let vatAmountSek: number | null = null
|
|
let totalSek: number | null = null
|
|
|
|
if (invoiceInput.currency !== 'SEK') {
|
|
const rateData = await fetchExchangeRate(invoiceInput.currency)
|
|
if (rateData) {
|
|
exchangeRate = rateData.rate
|
|
exchangeRateDate = rateData.date
|
|
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
|
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
|
totalSek = convertToSEK(total, exchangeRate)
|
|
}
|
|
}
|
|
|
|
let invoiceNumber: string | null = null
|
|
if (documentType === 'delivery_note') {
|
|
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
|
|
p_company_id: companyId,
|
|
})
|
|
invoiceNumber = dnNumber
|
|
}
|
|
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
customer_id: invoiceInput.customer_id,
|
|
invoice_number: invoiceNumber,
|
|
invoice_date: invoiceInput.invoice_date,
|
|
due_date: invoiceInput.due_date,
|
|
delivery_date: invoiceInput.delivery_date ?? null,
|
|
currency: invoiceInput.currency,
|
|
exchange_rate: exchangeRate,
|
|
exchange_rate_date: exchangeRateDate,
|
|
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
|
|
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
|
|
vat_amount: vatAmount,
|
|
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
|
|
total,
|
|
total_sek: documentType === 'delivery_note' ? null : totalSek,
|
|
vat_treatment: vatRules.treatment,
|
|
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
|
|
moms_ruta: vatRules.momsRuta,
|
|
reverse_charge_text: vatRules.reverseChargeText || null,
|
|
your_reference: invoiceInput.your_reference,
|
|
our_reference: invoiceInput.our_reference,
|
|
notes: invoiceInput.notes,
|
|
document_type: documentType,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError) {
|
|
log.error('invoice insert failed', invoiceError)
|
|
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
|
requestId,
|
|
details: { pgCode: invoiceError.code, pgMessage: invoiceError.message },
|
|
})
|
|
}
|
|
|
|
const items = invoiceInput.items.map((item, index) => {
|
|
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
|
const lineTotal = item.quantity * item.unit_price
|
|
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
|
|
return {
|
|
invoice_id: invoice.id,
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: lineTotal,
|
|
vat_rate: itemRate,
|
|
vat_amount: itemVat,
|
|
}
|
|
})
|
|
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(items)
|
|
|
|
if (itemsError) {
|
|
// Roll back invoice insert; otherwise the row is orphaned.
|
|
await supabase.from('invoices').delete().eq('id', invoice.id)
|
|
log.error('invoice items insert failed; rolled back invoice', itemsError, {
|
|
invoiceId: invoice.id,
|
|
})
|
|
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
|
requestId,
|
|
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
|
})
|
|
}
|
|
|
|
// 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(*)')
|
|
.eq('id', invoice.id)
|
|
.single()
|
|
|
|
// Emit event only for real invoices (proformas / delivery notes are informational).
|
|
if (completeInvoice && documentType === 'invoice') {
|
|
await eventBus.emit({
|
|
type: 'invoice.created',
|
|
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ data: completeInvoice })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|
|
|
|
async function createCreditNote(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
input: { credited_invoice_id: string; reason?: string },
|
|
log: Logger,
|
|
requestId: string,
|
|
) {
|
|
const { data: originalInvoice, error: originalError } = await supabase
|
|
.from('invoices')
|
|
.select('*, items:invoice_items(*)')
|
|
.eq('id', input.credited_invoice_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (originalError || !originalInvoice) {
|
|
return errorResponseFromCode('INVOICE_CREDIT_ORIGINAL_NOT_FOUND', log, { requestId })
|
|
}
|
|
|
|
if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') {
|
|
return errorResponseFromCode('INVOICE_CREDIT_NOT_INVOICE', log, {
|
|
requestId,
|
|
details: { documentType: originalInvoice.document_type },
|
|
})
|
|
}
|
|
|
|
if (originalInvoice.status === 'credited') {
|
|
return errorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', log, { requestId })
|
|
}
|
|
|
|
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
|
|
return errorResponseFromCode('INVOICE_CREDIT_NOT_SENT', log, {
|
|
requestId,
|
|
details: { currentStatus: originalInvoice.status },
|
|
})
|
|
}
|
|
|
|
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
|
|
|
const { data: creditNote, error: creditNoteError } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
customer_id: originalInvoice.customer_id,
|
|
invoice_number: creditNoteNumber,
|
|
invoice_date: new Date().toISOString().split('T')[0],
|
|
due_date: new Date().toISOString().split('T')[0],
|
|
delivery_date: originalInvoice.delivery_date ?? null,
|
|
currency: originalInvoice.currency,
|
|
exchange_rate: originalInvoice.exchange_rate,
|
|
exchange_rate_date: originalInvoice.exchange_rate_date,
|
|
subtotal: -Math.abs(originalInvoice.subtotal),
|
|
subtotal_sek: originalInvoice.subtotal_sek ? -Math.abs(originalInvoice.subtotal_sek) : null,
|
|
vat_amount: -Math.abs(originalInvoice.vat_amount),
|
|
vat_amount_sek: originalInvoice.vat_amount_sek ? -Math.abs(originalInvoice.vat_amount_sek) : null,
|
|
total: -Math.abs(originalInvoice.total),
|
|
total_sek: originalInvoice.total_sek ? -Math.abs(originalInvoice.total_sek) : null,
|
|
vat_treatment: originalInvoice.vat_treatment,
|
|
vat_rate: originalInvoice.vat_rate,
|
|
moms_ruta: originalInvoice.moms_ruta,
|
|
reverse_charge_text: originalInvoice.reverse_charge_text,
|
|
your_reference: originalInvoice.your_reference,
|
|
our_reference: originalInvoice.our_reference,
|
|
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
|
credited_invoice_id: input.credited_invoice_id,
|
|
status: 'sent',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (creditNoteError) {
|
|
log.error('credit note insert failed', creditNoteError)
|
|
return errorResponseFromCode('INVOICE_CREATE_INSERT_FAILED', log, {
|
|
requestId,
|
|
details: { pgCode: creditNoteError.code, pgMessage: creditNoteError.message },
|
|
})
|
|
}
|
|
|
|
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({
|
|
invoice_id: creditNote.id,
|
|
sort_order: item.sort_order,
|
|
description: item.description,
|
|
quantity: -Math.abs(item.quantity),
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
line_total: -Math.abs(item.line_total),
|
|
vat_rate: item.vat_rate ?? 0,
|
|
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
|
}))
|
|
|
|
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
|
|
|
|
if (itemsError) {
|
|
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
|
log.error('credit note items insert failed; rolled back', itemsError, {
|
|
creditNoteId: creditNote.id,
|
|
})
|
|
return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', log, {
|
|
requestId,
|
|
details: { pgCode: itemsError.code, pgMessage: itemsError.message },
|
|
})
|
|
}
|
|
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ status: 'credited' })
|
|
.eq('id', input.credited_invoice_id)
|
|
|
|
const { data: completeCreditNote } = await supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*), items:invoice_items(*)')
|
|
.eq('id', creditNote.id)
|
|
.single()
|
|
|
|
const { data: creditNoteSettings } = await supabase
|
|
.from('company_settings')
|
|
.select('entity_type, accounting_method')
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
|
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
|
|
|
|
// Cash method skips: there's no original invoice JE to reverse — recognition
|
|
// is deferred until refund.
|
|
if (completeCreditNote && accountingMethod === 'accrual') {
|
|
try {
|
|
const journalEntry = await createCreditNoteJournalEntry(
|
|
supabase,
|
|
companyId,
|
|
userId,
|
|
completeCreditNote as Invoice,
|
|
entityType,
|
|
completeCreditNote.customer?.name,
|
|
)
|
|
if (journalEntry) {
|
|
await supabase
|
|
.from('invoices')
|
|
.update({ journal_entry_id: journalEntry.id })
|
|
.eq('id', creditNote.id)
|
|
}
|
|
} catch (err) {
|
|
log.error('failed to create credit note journal entry', err as Error, {
|
|
creditNoteId: creditNote.id,
|
|
})
|
|
// Non-blocking — credit note still exists.
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'credit_note.created',
|
|
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ data: completeCreditNote })
|
|
}
|