Files
accounted/app/api/invoices/[id]/mark-paid/route.ts
T
MattssonandClaude Fable 5 f9ea9c0082 Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher

The booking engine resolves the series from
default_voucher_series_per_source_type, but the global "Standardserie"
dropdown wrote a separate field the engine ignored, and cash-method invoice
payments (invoice_cash_payment) weren't exposed in settings — so configured
series were silently dropped to "A".

- Expose cash/private payment source types in the per-source-type form
- Write the global default through to the map on save, keeping overrides
- Resolve voucher-sequences/next by source_type (+date) to match the engine
- Show the upcoming voucher (V2) in the payment dialog title
- Share resolveInvoicePaymentSourceType so preview and booking can't drift

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(salary): keep AGI panel in sync with Skatteverket signing state

The AGI panel mixed run-scoped generation state (agi_generated_at,
agi_declarations) with period-scoped submission state (extension_data
agi_submission_{period}), so the two could drift and present
contradictory UI. Reconcile them:

- Auto-detect a Mina Sidor BankID signature: while awaiting_signing,
  poll /agi/kvittenser on mount and on tab refocus so the panel flips
  to "signed" (hiding the signing actions) without a manual
  "Hamta kvittens" click.
- Warn instead of offering to sign when the locked granskningsunderlag
  predates the run's latest AGI generation (draftIsStale) — avoids
  filing superseded figures.
- Self-heal a stale "AGI-XML saknas" error once the run's AGI is
  (re)generated out-of-band (MCP/API/other tab).
- Refetch the salary run on tab focus so agi_generated_at reflects
  out-of-band generation without a hard reload.
- /agi/lasUpp now clears the cached agi_submission_{period} record, so
  unlocking drops the panel back to the pre-submission state instead of
  stranding it on a released "redo att signeras" draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: Implement VAT registration handling and invoice item line types

- Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies.
- Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly.
- Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field.
- Enhanced invoice and credit note handling to accommodate new line types.
- Added new localized messages for text rows in English and Swedish.
- Created tests for salary run approval logic, ensuring bank details are validated correctly.
- Implemented effective net payout calculation for salary runs, considering tax overrides.
- Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers.

* feat(articles): artikelregister with revenue account + VAT rate per article

Article register (non-inventory) with per-article VAT rate and optional
BAS class-3 revenue-account override. Includes API routes, UI pages,
MCP tools, pending-operation staging, and the activate-or-create
account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog,
unknown numbers -> AddAccountDialog) reusing the journal entry UX.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): no-doc-required batch + bulk-missing endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(payments): supplier payment lines + cash-method invoice matching

Shared payment-line proposal for supplier invoices, improved
match-invoice/match-supplier-invoice flows (kontantmetoden-aware),
and voucher-link support without requiring a 151x clearing entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc

New journal entry dialog component, journal list/page updates,
invoice editor updates, SIE import adjustments, transaction ingest
and api-key tweaks, pr-agent workflow update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): implement tax reduction features and localization updates

* feat(tests): add VAT registration gate to pending operations commit tests

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:52:24 +02:00

266 lines
10 KiB
TypeScript

import { NextResponse } from 'next/server'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized()
/**
* POST /api/invoices/[id]/mark-paid
*
* Manually marks an invoice as paid (for payments received outside bank sync).
*
* Faktureringsmetoden (accrual): Debit 1930, Credit 1510 (clearing entry)
* Kontantmetoden (cash): Debit 1930, Credit 30xx, Credit 26xx
*/
export const POST = withRouteContext(
'invoice.mark_paid',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ invoiceId: id })
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) {
return errorResponseFromCode('INVOICE_PAID_NOT_FOUND', opLog, { requestId })
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
// Optional body. Backwards-compat: callers may POST with no body.
let exchangeRateDifference: number | undefined
let bodyPaymentDate: string | undefined
let customLines: { account_number: string; debit_amount: number; credit_amount: number; line_description?: string }[] | undefined
let force = false
let rawBody: unknown
try {
const text = await request.text()
if (text) rawBody = JSON.parse(text)
} catch {
// Empty / invalid body — fall through to defaults.
}
if (rawBody) {
const parsed = MarkInvoicePaidSchema.safeParse(rawBody)
if (!parsed.success) {
opLog.warn('mark-paid validation failed', {
issueCount: parsed.error.issues.length,
})
return NextResponse.json(
{ error: 'Ogiltig förfrågan', details: parsed.error.flatten() },
{ status: 400 },
)
}
exchangeRateDifference = parsed.data.exchange_rate_difference
bodyPaymentDate = parsed.data.payment_date
customLines = parsed.data.lines
force = parsed.data.force === true
}
const now = new Date().toISOString()
const paymentDate = bodyPaymentDate || now.split('T')[0]
// Duplicate-payment guard: surface a likely-matching unlinked inbound bank
// transaction before booking. Skipped on partial payments (explicit,
// deliberate action), on force=true, and on invoices without a resolved
// customer name. Mirrors the supplier-side guard at
// /api/supplier-invoices/[id]/mark-paid. The dialog always sends custom
// lines, so the partial-payment skip is gated on total debit vs remaining,
// not on the mere presence of customLines.
const remainingAmount =
(invoice as Invoice & { remaining_amount?: number }).remaining_amount ?? invoice.total
const paymentAmount = customLines
? customLines.reduce((s, l) => s + l.debit_amount, 0)
: remainingAmount
const paidRounded = Math.round(paymentAmount * 100) / 100
const remainingRounded = Math.round(remainingAmount * 100) / 100
if (!force && paidRounded >= remainingRounded) {
const customerName = (invoice as Invoice & { customer?: { name?: string } }).customer?.name
if (!customerName) {
opLog.warn('duplicate-payment guard skipped', {
reason: 'missing_customer_name',
invoiceId: id,
})
} else {
const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, {
companyId: companyId!,
invoice: { invoice_number: invoice.invoice_number, customer_name: customerName },
paymentAmount,
paymentDate,
})
if (candidates.length > 0) {
return errorResponseFromCode('INVOICE_PAID_LIKELY_DUPLICATE', opLog, {
requestId,
details: { candidates },
})
}
}
} else if (force) {
opLog.warn('duplicate-payment guard bypassed', {
reason: 'force=true',
invoiceId: id,
userId: user.id,
paymentAmount,
})
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.eq('company_id', companyId)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Drive the JE shape from the invoice's actual booking state, not from
// the current accounting_method setting. If the invoice was booked at
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510 —
// otherwise the receivable orphans and 30xx + VAT double-count. Only
// when there is no prior JE (pure kontantmetoden) do we recognise
// revenue + VAT here.
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (isRealInvoice) {
try {
if (customLines) {
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
requestId,
details: { totalDebit, totalCredit },
})
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
if (!fiscalPeriodId) {
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
requestId,
details: { paymentDate },
})
}
const sourceType = resolveInvoicePaymentSourceType({
invoiceAlreadyBooked,
accountingMethod,
})
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: paymentDate,
description: invoice.customer?.name
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
: `Inbetalning kundfaktura ${invoice.invoice_number}`,
source_type: sourceType,
source_id: invoice.id,
lines: customLines,
}
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
journalEntryId = journalEntry?.id ?? null
} else if (useCashEntry) {
const journalEntry = await createInvoiceCashEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
entityType, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
} else {
const journalEntry = await createInvoicePaymentJournalEntry(
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
exchangeRateDifference, invoice.customer?.name,
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (isBookkeepingError(err)) {
return errorResponse(err, opLog, { requestId })
}
opLog.error('failed to create payment journal entry', err as Error)
return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, {
requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
}
// CAS guard: only update if status is still in a payable state.
const { data: updateResult, error: updateError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
})
.eq('id', id)
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.select('id')
if (updateError) {
opLog.error('failed to update invoice status', updateError)
return errorResponse(updateError, opLog, { requestId })
}
if (!updateResult || updateResult.length === 0) {
// Status changed between read and write — cancel the orphaned JE and
// document the voucher gap before reporting back.
if (journalEntryId) {
const { data: orphan } = await supabase
.from('journal_entries')
.select('fiscal_period_id, voucher_series, voucher_number')
.eq('id', journalEntryId)
.single()
await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.eq('id', journalEntryId)
if (orphan) {
await supabase.from('voucher_gap_explanations').insert({
company_id: companyId,
fiscal_period_id: orphan.fiscal_period_id,
voucher_series: orphan.voucher_series || 'A',
gap_number: orphan.voucher_number,
explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
created_by: user.id,
})
}
}
return errorResponseFromCode('INVOICE_PAID_RACE', opLog, { requestId })
}
return NextResponse.json({
success: true,
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
journal_entry_id: journalEntryId,
})
},
{ requireWrite: true },
)