Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <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,
|
|
})
|
|
},
|
|
)
|