fix(bookkeeping): settle öre differences to 3740 and improve supplier-invoice matching (#699)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4dfd790de5
commit
b38b3d0230
@@ -18,7 +18,7 @@ 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 } from '@/lib/money'
|
||||
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'
|
||||
@@ -170,7 +170,13 @@ export const GET = withRouteContext(
|
||||
// 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
|
||||
const isFullyPaid = !fxRateUnavailable && newRemaining <= 0
|
||||
// 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
|
||||
|
||||
@@ -300,7 +300,12 @@ export const POST = withRouteContext(
|
||||
// Overshoot guard + paid/remaining math — shared with the v1 and agent
|
||||
// (commit) paths via planInvoicePayment so they cannot drift again. Runs
|
||||
// before any JE is created, so a doomed match never burns a voucher number.
|
||||
const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency)
|
||||
// Pure-SEK settlements absorb sub-krona öresavrundning (booked to 3740 by
|
||||
// buildInvoicePaymentClearingLines) so a whole-krona payment settles in full.
|
||||
const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
|
||||
const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency, {
|
||||
absorbOreRounding: pureSek,
|
||||
})
|
||||
if (!payment.ok) {
|
||||
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId,
|
||||
|
||||
@@ -38,6 +38,16 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
|
||||
}))
|
||||
|
||||
// Pure-SEK clearing now posts via the shared builder + createJournalEntry
|
||||
// (not createSupplierInvoicePaymentEntry). Mock the engine so that path doesn't
|
||||
// hit the queued Supabase mock.
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -48,6 +58,8 @@ beforeEach(() => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
})
|
||||
|
||||
const TX_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
@@ -109,17 +121,26 @@ function enqueueHappyPath(opts: {
|
||||
}
|
||||
|
||||
describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => {
|
||||
it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => {
|
||||
it('books a clean SEK clearing entry (no FX) for a SEK tx paying a SEK invoice', async () => {
|
||||
// SEK/SEK now routes through buildSupplierPaymentClearingLines +
|
||||
// createJournalEntry, not createSupplierInvoicePaymentEntry. An exact
|
||||
// payment yields just Dr 2440 / Cr 1930 — no 3960/7960 FX line, no 3740.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -2390, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 2390 },
|
||||
})
|
||||
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
|
||||
const args = mockCreatePaymentEntry.mock.calls[0]
|
||||
// (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?)
|
||||
expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK
|
||||
expect(args[6]).toBeUndefined() // no FX diff
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.lines).toHaveLength(2)
|
||||
expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(2390)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(2390)
|
||||
expect(
|
||||
input.lines.some((l) => ['3960', '7960', '3740'].includes(l.account_number)),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
|
||||
@@ -197,6 +218,32 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => {
|
||||
// The reported bug: invoice 11 231,25, bank paid 11 231 → previously left
|
||||
// 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -11231, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
invoice_status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.paid_amount).toBe(11231.25)
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
const input = mockCreateJournalEntry.mock.calls[0][3] as {
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(11231.25)
|
||||
expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(11231)
|
||||
expect(input.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25)
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => {
|
||||
// Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
|
||||
// would push paid_amount past invoice.total. The new guard rejects so the
|
||||
@@ -314,4 +361,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + F
|
||||
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT absorb öre under the cash method — a SEK sub-krona diff stays partial', async () => {
|
||||
// Kontantmetoden books the full invoice via the cash entry (not the bank
|
||||
// amount), so folding the 0,25 to 3740 would hide a 1930 discrepancy. The
|
||||
// öre band is accrual-only; here the invoice stays partially_paid.
|
||||
enqueueHappyPath({
|
||||
transaction: { amount: -11231, currency: 'SEK' },
|
||||
invoice: { currency: 'SEK', remaining_amount: 11231.25 },
|
||||
accountingMethod: 'cash',
|
||||
})
|
||||
const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
invoice_status: string
|
||||
remaining_amount: number
|
||||
}>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled() // no 3740 clearing entry
|
||||
expect(body.invoice_status).toBe('partially_paid')
|
||||
expect(body.remaining_amount).toBe(0.25)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
|
||||
import { ORE_TOLERANCE } from '@/lib/money'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
type PreviewLine = {
|
||||
@@ -81,6 +83,10 @@ export const GET = withRouteContext(
|
||||
|
||||
const lines: PreviewLine[] = []
|
||||
let entryType: 'clearing' | 'cash' = 'clearing'
|
||||
// Drives the dialog's "markeras som betald" / öresavrundning copy. Cash
|
||||
// entries always book the full invoice, so they default to fully paid.
|
||||
let isFullyPaid = true
|
||||
let oreRounding = false
|
||||
|
||||
if (useCashEntry) {
|
||||
entryType = 'cash'
|
||||
@@ -158,26 +164,52 @@ export const GET = withRouteContext(
|
||||
} else {
|
||||
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
|
||||
const si = invoice as SupplierInvoice
|
||||
const amountSek = resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
null,
|
||||
transaction.currency,
|
||||
null,
|
||||
)
|
||||
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
|
||||
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
description: 'Utbetalning från bank',
|
||||
})
|
||||
const isPureSek = transaction.currency === 'SEK' && si.currency === 'SEK'
|
||||
if (isPureSek) {
|
||||
// Shared builder so the previewed lines — including any 3740
|
||||
// öresavrundning row — are byte-identical to what the POST commits.
|
||||
const remainingSek = si.remaining_amount ?? si.total
|
||||
const bankSek = Math.abs(transaction.amount)
|
||||
const { lines: clearingLines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
||||
apSek: remainingSek,
|
||||
bankSek,
|
||||
paymentAccount,
|
||||
})
|
||||
for (const l of clearingLines) {
|
||||
lines.push({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
description: l.line_description ?? '',
|
||||
})
|
||||
}
|
||||
oreRounding = oreDiffSek !== 0
|
||||
// Full settlement when the öre residual is absorbed or the bank covers
|
||||
// the whole remaining; a ≥1 kr short payment leaves a partial.
|
||||
isFullyPaid = oreRounding || bankSek >= remainingSek - ORE_TOLERANCE
|
||||
} else {
|
||||
const amountSek = resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
null,
|
||||
transaction.currency,
|
||||
null,
|
||||
)
|
||||
const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
|
||||
const amount = Math.round(Math.min(amountSek, total) * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
description: 'Utbetalning från bank',
|
||||
})
|
||||
isFullyPaid = amount >= total - ORE_TOLERANCE
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -185,6 +217,8 @@ export const GET = withRouteContext(
|
||||
lines,
|
||||
invoice_already_booked: siAlreadyBooked,
|
||||
accounting_method: accountingMethod,
|
||||
is_fully_paid: isFullyPaid,
|
||||
ore_rounding: oreRounding,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
|
||||
import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -82,26 +84,10 @@ export const POST = withRouteContext(
|
||||
|
||||
const txAmountAbs = Math.abs(transaction.amount)
|
||||
|
||||
// Overshoot guard for the same-currency branch. The legacy code path used
|
||||
// txAmountAbs wholesale and would push supplier_invoices.paid_amount past
|
||||
// invoice.total whenever the bank transaction was larger than what was
|
||||
// owed. Reject and direct the user at the split-payment flow which can
|
||||
// allocate the excess to additional supplier invoices.
|
||||
// FX branch (currency mismatch) is already clamped below to
|
||||
// invoice.remaining_amount, so it cannot overshoot.
|
||||
if (
|
||||
transaction.currency === invoice.currency &&
|
||||
txAmountAbs > invoice.remaining_amount + 0.005
|
||||
) {
|
||||
return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_amount: txAmountAbs,
|
||||
remaining_amount: Math.round(invoice.remaining_amount * 100) / 100,
|
||||
excess: Math.round((txAmountAbs - invoice.remaining_amount) * 100) / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Pure SEK settlements route through the shared clearing builder so öre
|
||||
// rounding lands on 3740 and the invoice settles in full; foreign legs keep
|
||||
// the kursvinst/kursförlust path in createSupplierInvoicePaymentEntry.
|
||||
const isPureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
|
||||
|
||||
// Amount in the *invoice's* currency — used to update
|
||||
// supplier_invoices.paid_amount/remaining_amount and the
|
||||
@@ -116,6 +102,43 @@ export const POST = withRouteContext(
|
||||
? txAmountAbs
|
||||
: invoice.remaining_amount
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, last_supplier_payment_account')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
// Same default the preview route uses, so the committed verifikat credits the
|
||||
// same account the user saw previewed (the old path hardcoded 1930 here).
|
||||
const paymentAccount =
|
||||
(settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930'
|
||||
|
||||
// Route on the supplier invoice's actual booking state — if 2440 was posted
|
||||
// at receipt (accrual), the match clears 2440 regardless of the company's
|
||||
// current setting. Only true kontantmetoden invoices (no registration JE)
|
||||
// book expense + input VAT here.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Ledger math + overshoot guard in one place (mirrors planInvoicePayment on
|
||||
// the customer side). Öre absorption applies ONLY to the accrual SEK clearing
|
||||
// path: there a whole-krona payment within 1 kr settles the invoice in full
|
||||
// and the residual is booked to 3740 by the line builder. Cash-method entries
|
||||
// book the full invoice total (not the bank amount), so absorbing there would
|
||||
// mark the invoice paid while leaving a hidden 1930 discrepancy — keep strict.
|
||||
// Rejecting here, BEFORE any JE is created, keeps a doomed overshoot from
|
||||
// burning a voucher number.
|
||||
const paymentPlan = planSupplierPayment(invoice, paymentAmountInvoiceCurrency, {
|
||||
absorbOreRounding: isPureSek && !useCashEntry,
|
||||
})
|
||||
if (!paymentPlan.ok) {
|
||||
return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, {
|
||||
requestId,
|
||||
details: paymentPlan.details,
|
||||
})
|
||||
}
|
||||
|
||||
// SEK that actually left the bank, when we know it. SEK transaction → the
|
||||
// absolute amount; foreign transaction with a stored amount_sek → that
|
||||
// value; foreign transaction WITHOUT amount_sek → unknown (null). The raw
|
||||
@@ -162,21 +185,6 @@ export const POST = withRouteContext(
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Route on the supplier invoice's actual booking state — if 2440 was
|
||||
// posted at receipt (accrual), the match must clear 2440 regardless of
|
||||
// the company's current setting. Only true kontantmetoden invoices
|
||||
// (no registration JE) book expense + input VAT here.
|
||||
const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
|
||||
const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// A full settlement pays off the whole remaining balance. Cross-currency
|
||||
// matches always do (paymentAmountInvoiceCurrency is clamped to
|
||||
// invoice.remaining_amount above); same-currency does when the bank amount
|
||||
@@ -205,6 +213,11 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Verifikat header description, shared by every booking branch below.
|
||||
const desc = invoice.supplier?.name
|
||||
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
@@ -226,9 +239,6 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
|
||||
const desc = invoice.supplier?.name
|
||||
? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
|
||||
: `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
@@ -252,6 +262,32 @@ export const POST = withRouteContext(
|
||||
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else if (isPureSek) {
|
||||
// SEK clearing through the shared builder: a sub-krona difference is
|
||||
// booked to 3740 and 2440 is cleared in full (invoice → paid); an exact
|
||||
// or ≥1 kr-short payment clears what moved. Byte-identical to the preview
|
||||
// (same payment account + line descriptions). No FX here by definition.
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
|
||||
requestId,
|
||||
details: { paymentDate: transaction.date },
|
||||
})
|
||||
}
|
||||
const { lines } = buildSupplierPaymentClearingLines({
|
||||
apSek: invoice.remaining_amount,
|
||||
bankSek: txAmountAbs,
|
||||
paymentAccount,
|
||||
})
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: 'supplier_invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
})
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
supabase, companyId, user.id, invoice as SupplierInvoice,
|
||||
@@ -271,10 +307,10 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100)
|
||||
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
// Ledger update from the plan computed up front. An öre-absorbed settlement
|
||||
// reports remaining 0 / status paid even though the bank paid a sub-krona
|
||||
// less (or more) — the residual lives on 3740, not the supplier ledger.
|
||||
const { newRemaining, newPaidAmount, isFullyPaid, newStatus } = paymentPlan.plan
|
||||
|
||||
const { data: updatedRows, error: updateInvError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
|
||||
Reference in New Issue
Block a user