* 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>
266 lines
10 KiB
TypeScript
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 },
|
|
)
|