Whole-krona Bankgiro/Swish payments of öre-bearing invoices were stranded as partially_paid forever (e.g. 11 231 paid on an 11 231,25 invoice left 0,25 kr open). Book the sub-krona residual to BAS 3740 (Öres- och kronutjämning) and settle the invoice in full, on both the supplier- and customer-invoice match flows. New shared pure helpers buildSupplierPaymentClearingLines + planSupplierPayment mirror the customer-side primitives; routing preview and commit through the same builder also fixes two pre-existing preview↔commit drifts (payment account + line descriptions). Öre absorption is accrual-only — cash entries book the full invoice, so absorbing there would hide a 1930 discrepancy. Also improves supplier-invoice ↔ bank matching: - Pass-3 date window now spans [invoice_date-5, due_date+5] instead of due_date ±5, so early payments auto-match; an ambiguity guard demotes non-unique amount matches to suggestions. - New retroactive matcher (on supplier_invoice.registered/.approved) surfaces the settling bank payment when the invoice is registered after the payment was imported. Matches are written as suggestions for one-click confirm-to-book, never silently auto-booked. Tests: new unit tests for both pure helpers; extended matching, handler, customer öre, and route suites. Full suite green (407 files / 5364 tests). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
/**
|
||
* GET /api/transactions/[id]/match-invoice/preview?invoice_id=...
|
||
*
|
||
* Returns the journal entry lines that match-invoice would create for this
|
||
* (transaction, invoice) pair. Read-only — does not stage or write anything.
|
||
*
|
||
* The shape mirrors the routing decision in the POST handler: if the invoice
|
||
* was already booked (invoice.journal_entry_id is set, i.e. 1510 is on the
|
||
* books), we preview the clearing entry (Dr 1930 / Cr 1510). Only when the
|
||
* invoice was never booked AND the company is on kontantmetoden AND the
|
||
* receipt fully pays the invoice do we preview the cash entry (Dr 1930 /
|
||
* Cr 30xx / Cr 26xx).
|
||
*
|
||
* The UI uses this to show the user the exact lines before they confirm —
|
||
* the lack of any preview was part of the reported bug.
|
||
*/
|
||
import { NextResponse } from 'next/server'
|
||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||
import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
|
||
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
|
||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||
import type { Currency, EntityType, Invoice, InvoiceItem } from '@/types'
|
||
import { z } from 'zod'
|
||
|
||
type PreviewLine = {
|
||
account_number: string
|
||
debit_amount: number
|
||
credit_amount: number
|
||
description: string
|
||
}
|
||
|
||
const QuerySchema = z.object({
|
||
invoice_id: z.string().uuid(),
|
||
})
|
||
|
||
export const GET = withRouteContext(
|
||
'transaction.match_invoice_preview',
|
||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||
const { id: transactionId } = await params
|
||
const { supabase, companyId, log, requestId } = ctx
|
||
|
||
const url = new URL(request.url)
|
||
const parsed = QuerySchema.safeParse({ invoice_id: url.searchParams.get('invoice_id') })
|
||
if (!parsed.success) {
|
||
return errorResponseFromCode('VALIDATION_ERROR', log, {
|
||
requestId,
|
||
details: { field: 'invoice_id', message: 'invoice_id must be a UUID' },
|
||
})
|
||
}
|
||
const { invoice_id } = parsed.data
|
||
|
||
// Data minimization (GDPR Art.5(1)(c)): amount_sek + exchange_rate are
|
||
// pulled because buildInvoicePaymentClearingLines needs them for the
|
||
// cross-currency bank-leg math (round-7 FX fix). All other columns
|
||
// would broaden the projection without serving the preview's purpose.
|
||
const { data: transaction, error: txErr } = await supabase
|
||
.from('transactions')
|
||
.select('id, date, amount, amount_sek, currency, exchange_rate')
|
||
.eq('id', transactionId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
if (txErr || !transaction) {
|
||
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
|
||
}
|
||
|
||
const { data: invoice, error: invErr } = await supabase
|
||
.from('invoices')
|
||
.select('*, items:invoice_items(*)')
|
||
.eq('id', invoice_id)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
if (invErr || !invoice) {
|
||
return errorResponseFromCode('MATCH_INVOICE_NOT_FOUND', log, { requestId })
|
||
}
|
||
|
||
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: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||
|
||
// Cross-currency FX preview. When tx.currency !== invoice.currency we fetch
|
||
// the Riksbanken spot rate for invoice.currency on the tx date and surface
|
||
// the conversion to the dialog (the user sees the rate + invoice-currency-
|
||
// equivalent before approving). The committed verifikat uses the same
|
||
// numbers; the route POST handler re-runs the lookup so the rate at
|
||
// commit time is authoritative.
|
||
//
|
||
// This MUST run BEFORE the paid / remaining / fully-paid math below.
|
||
// invoice.remaining_amount and invoice.total are denominated in INVOICE
|
||
// currency, so a SEK bank tx has to be converted first. Computing the
|
||
// comparison from the raw SEK amount made a 1 000 SEK payment look like
|
||
// it fully cleared a 140 USD invoice (newRemaining went negative →
|
||
// isFullyPaid=true), which for a cash-method unbooked invoice previewed a
|
||
// cash entry (Dr 1930 / Cr 30xx) that the POST — which converts first —
|
||
// would never commit (it posts the clearing entry Dr 1930 / Cr 1510).
|
||
//
|
||
// Per ML 8 kap 21–23§ the rate effective on the payment date is the
|
||
// correct conversion. If the lookup fails (Riksbanken outage, missing
|
||
// rate for that date), the response carries `fx_conversion.error` and
|
||
// the dialog can surface a manual-rate input field instead.
|
||
type FxConversion =
|
||
| {
|
||
required: true
|
||
tx_currency: string
|
||
invoice_currency: string
|
||
rate: number
|
||
rate_date: string
|
||
paid_in_invoice_currency: number
|
||
}
|
||
| { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string }
|
||
| { required: false }
|
||
|
||
let fxConversion: FxConversion = { required: false }
|
||
if (transaction.currency !== invoice.currency) {
|
||
const rateInfo = await fetchExchangeRate(
|
||
invoice.currency as Currency,
|
||
new Date(transaction.date),
|
||
)
|
||
if (rateInfo && rateInfo.rate > 0) {
|
||
// bankSek / rate = how many units of invoice.currency this payment
|
||
// satisfies. Round to 4 decimal places to preserve precision through
|
||
// subsequent partial-payment accumulations.
|
||
const txAbsSek =
|
||
transaction.currency === 'SEK'
|
||
? Math.abs(transaction.amount)
|
||
: Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1)
|
||
const paidInInvoiceCurrency =
|
||
Math.round((txAbsSek / rateInfo.rate) * 10000) / 10000
|
||
fxConversion = {
|
||
required: true,
|
||
tx_currency: transaction.currency,
|
||
invoice_currency: invoice.currency,
|
||
rate: rateInfo.rate,
|
||
rate_date: rateInfo.date,
|
||
paid_in_invoice_currency: paidInInvoiceCurrency,
|
||
}
|
||
} else {
|
||
fxConversion = {
|
||
required: true,
|
||
error: 'rate_unavailable',
|
||
tx_currency: transaction.currency,
|
||
invoice_currency: invoice.currency,
|
||
}
|
||
}
|
||
}
|
||
|
||
// paidAmount is denominated in INVOICE currency so the remaining /
|
||
// fully-paid comparison is like-with-like. Same-currency → tx.amount.
|
||
// Cross-currency with a resolved rate → the spot-rate conversion (mirrors
|
||
// the POST handler's paidAmountInInvoiceCurrency).
|
||
const paidAmount =
|
||
fxConversion.required && !('error' in fxConversion)
|
||
? fxConversion.paid_in_invoice_currency
|
||
: transaction.amount
|
||
const currentRemaining =
|
||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
|
||
const newRemaining = Math.max(
|
||
0,
|
||
Math.round((currentRemaining - paidAmount) * 100) / 100,
|
||
)
|
||
// A rate-unavailable cross-currency payment can't be resolved to invoice
|
||
// currency yet, so never report fully-paid (or preview the cash shape) on
|
||
// a guess — the dialog blocks confirm until a manual rate is entered and
|
||
// the POST recomputes the real figure.
|
||
const fxRateUnavailable = fxConversion.required && 'error' in fxConversion
|
||
// Pure-SEK whole-krona settlements absorb a sub-krona remainder as
|
||
// öresavrundning (3740) and settle in full — mirror that here so the
|
||
// preview's fully-paid signal matches the committed verifikat.
|
||
const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
|
||
const isFullyPaid =
|
||
!fxRateUnavailable &&
|
||
(newRemaining <= 0 || (pureSek && newRemaining < ORE_ROUNDING_SETTLEMENT_MAX))
|
||
|
||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
|
||
|
||
const lines: PreviewLine[] = []
|
||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||
|
||
if (useCashEntry) {
|
||
entryType = 'cash'
|
||
// Mirror createInvoiceCashEntry: per-rate revenue + VAT credits, 1930 debit.
|
||
const inv = invoice as Invoice & { items?: InvoiceItem[] }
|
||
const items = inv.items ?? []
|
||
const isForeign = inv.currency !== 'SEK'
|
||
|
||
// Per-item rate aggregation (matches generatePerRateLines semantics).
|
||
// InvoiceItem.line_total is the NET line amount (EXCLUDES VAT) — it sums
|
||
// to invoice.subtotal, and each line's vat_amount = line_total * rate. The
|
||
// commit path (generatePerRateLines) credits revenue with line_total
|
||
// directly; subtracting vat here double-subtracts VAT and unbalances the
|
||
// previewed verifikat against the 1930 debit (inv.total).
|
||
const byRate = new Map<number, { subtotal: number; vat: number }>()
|
||
if (items.length > 0) {
|
||
for (const it of items) {
|
||
const rate = it.vat_rate ?? 25
|
||
const itemVat = resolveSekAmount(it.vat_amount, null, inv.currency, inv.exchange_rate)
|
||
const itemTotal = resolveSekAmount(it.line_total, null, inv.currency, inv.exchange_rate)
|
||
const sub = roundOre(itemTotal)
|
||
const bucket = byRate.get(rate) ?? { subtotal: 0, vat: 0 }
|
||
bucket.subtotal += sub
|
||
bucket.vat += itemVat
|
||
byRate.set(rate, bucket)
|
||
}
|
||
} else {
|
||
// Fallback to invoice-level totals
|
||
const sub = resolveSekAmount(inv.subtotal, inv.subtotal_sek, inv.currency, inv.exchange_rate)
|
||
const vat = resolveSekAmount(inv.vat_amount, inv.vat_amount_sek, inv.currency, inv.exchange_rate)
|
||
byRate.set(inv.vat_rate ?? 25, { subtotal: sub, vat })
|
||
}
|
||
|
||
const creditLines: PreviewLine[] = []
|
||
for (const [rate, totals] of byRate) {
|
||
const vatTreatment = totals.vat > 0
|
||
? (rate === 25 ? 'standard_25' : rate === 12 ? 'reduced_12' : rate === 6 ? 'reduced_6' : inv.vat_treatment)
|
||
: inv.vat_treatment
|
||
const revenueAcct = getRevenueAccount(vatTreatment, entityType)
|
||
creditLines.push({
|
||
account_number: revenueAcct,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(totals.subtotal * 100) / 100,
|
||
description: `Försäljning ${rate}%`,
|
||
})
|
||
if (totals.vat > 0) {
|
||
creditLines.push({
|
||
account_number: getOutputVatAccount(vatTreatment),
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(totals.vat * 100) / 100,
|
||
description: `Utgående moms ${rate}%`,
|
||
})
|
||
}
|
||
}
|
||
|
||
const totalCredits = creditLines.reduce((s, l) => s + l.credit_amount, 0)
|
||
const cashDebit = isForeign
|
||
? Math.round(totalCredits * 100) / 100
|
||
: resolveSekAmount(inv.total, inv.total_sek, inv.currency, inv.exchange_rate)
|
||
|
||
lines.push({
|
||
account_number: '1930',
|
||
debit_amount: Math.round(cashDebit * 100) / 100,
|
||
credit_amount: 0,
|
||
description: 'Inbetalning från bank',
|
||
})
|
||
lines.push(...creditLines)
|
||
} else {
|
||
// Clearing entry. Delegates to the shared helper so the preview and
|
||
// the committed verifikat are byte-identical — fixing the prior
|
||
// bug where the preview ran `resolveSekAmount(tx.amount, null,
|
||
// INV.currency, INV.rate)`, treating the SEK tx number as if it
|
||
// were in the invoice's currency and multiplying by the invoice's
|
||
// rate. That produced a fictitious bank-leg and silently dropped
|
||
// the FX gain/loss for cross-currency invoices.
|
||
const inv = invoice as Invoice
|
||
const { lines: clearingLines } = buildInvoicePaymentClearingLines(
|
||
{
|
||
amount: transaction.amount,
|
||
amount_sek: transaction.amount_sek ?? null,
|
||
currency: transaction.currency,
|
||
exchange_rate: transaction.exchange_rate ?? null,
|
||
},
|
||
{
|
||
currency: inv.currency,
|
||
exchange_rate: inv.exchange_rate ?? null,
|
||
remaining_amount: inv.remaining_amount ?? null,
|
||
total: inv.total,
|
||
paid_amount: inv.paid_amount ?? null,
|
||
},
|
||
'Inbetalning kundfaktura',
|
||
fxConversion.required && !('error' in fxConversion)
|
||
? fxConversion.paid_in_invoice_currency
|
||
: undefined,
|
||
)
|
||
for (const line of clearingLines) {
|
||
lines.push({
|
||
account_number: line.account_number,
|
||
debit_amount: line.debit_amount,
|
||
credit_amount: line.credit_amount,
|
||
description: line.line_description ?? '',
|
||
})
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
entry_type: entryType,
|
||
lines,
|
||
invoice_already_booked: invoiceAlreadyBooked,
|
||
accounting_method: accountingMethod,
|
||
is_fully_paid: isFullyPaid,
|
||
fx_conversion: fxConversion,
|
||
})
|
||
},
|
||
)
|