fix(matching): bring the MCP match-invoice commit path to parity with the dashboard route (#1646)
commitMatchTransactionInvoice fed the raw bank amount into planInvoicePayment with no currency conversion and no absorbOreRounding, both of which the dashboard and v1 routes do. Consequences: an exact whole-krona settlement of an ore-carrying invoice was rejected as MATCH_AMOUNT_EXCEEDS_REMAINING, and a cross-currency match would have recorded SEK figures in invoice-currency columns. Parity changes, mirroring the v1 route block: - FX resolution: refuse foreign rows with no SEK value (MATCH_INVOICE_TX_FX_RATE_MISSING), convert via the Riksbanken spot rate on the payment date, refuse when no rate exists (MATCH_INVOICE_FX_RATE_UNAVAILABLE). - planInvoicePayment gets absorbOreRounding on pure-SEK settlements. - The accrual clearing entry is now built by the shared buildInvoicePaymentClearingLines helper (same as dashboard/v1), so the 3740 oresavrundning line and 3960/7960 FX-diff lines exist and 1510 is credited at the invoice's booking rate. Dimension re-propagation included. Failure semantics preserved (no fiscal period still soft-fails like the old builder). - invoice_payments.exchange_rate records the payment-date rate on cross-currency matches. Reported via gnubok_feedback 2026-07-24 (codex/hermes: 14 875 SEK against exactly 14 875 outstanding rejected as exceeding remaining). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
e70718eea3
commit
15a1c96292
@@ -30,6 +30,8 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
||||
})
|
||||
|
||||
const mockReverseEntry = vi.fn()
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/engine')>(
|
||||
'@/lib/bookkeeping/engine',
|
||||
@@ -37,6 +39,8 @@ vi.mock('@/lib/bookkeeping/engine', async () => {
|
||||
return {
|
||||
...actual,
|
||||
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -125,6 +129,10 @@ beforeEach(() => {
|
||||
mockCreateCashEntry.mockResolvedValue({ id: 'je-pay' })
|
||||
mockCreateCreditNoteEntry.mockResolvedValue({ id: 'je-credit' })
|
||||
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
|
||||
// The accrual match path builds its clearing entry via findFiscalPeriod +
|
||||
// createJournalEntry (shared-builder parity with the dashboard/v1 routes).
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-pay' })
|
||||
})
|
||||
|
||||
describe('match_transaction_invoice: partial commit after the storno', () => {
|
||||
@@ -138,7 +146,7 @@ describe('match_transaction_invoice: partial commit after the storno', () => {
|
||||
enqueue({ data: null, error: null }) // transactions unlink after storno
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
mockCreatePaymentEntry.mockRejectedValue(new JournalEntryNotBalancedError(500, 400))
|
||||
mockCreateJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(500, 400))
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
@@ -235,7 +243,7 @@ describe('match_transaction_invoice: partial commit after the storno', () => {
|
||||
|
||||
// JE creation "succeeded" with null (e.g. no open fiscal period):
|
||||
// nothing was posted, so today's auto-reject semantics must survive.
|
||||
mockCreatePaymentEntry.mockResolvedValue(null)
|
||||
mockCreateJournalEntry.mockResolvedValue(null)
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
+215
-14
@@ -27,6 +27,36 @@ vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// The accrual branch books via findFiscalPeriod + createJournalEntry with
|
||||
// lines from the shared buildInvoicePaymentClearingLines helper (parity with
|
||||
// the dashboard/v1 routes); the settlement account shows up as the bank leg's
|
||||
// account_number.
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/engine')>(
|
||||
'@/lib/bookkeeping/engine',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Riksbanken lookup for cross-currency parity tests: mocked so no network or
|
||||
// DB call happens; per-test mockResolvedValue supplies the rate.
|
||||
const mockFetchExchangeRate = vi.fn()
|
||||
vi.mock('@/lib/currency/riksbanken', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/currency/riksbanken')>(
|
||||
'@/lib/currency/riksbanken',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Issue #1259: settling the invoice retires the suggestion pointers at it.
|
||||
// Mocked so it consumes no slot in the queued Supabase mock; the helper's own
|
||||
// query shape is pinned by lib/invoices/__tests__/clear-settled-invoice-suggestions.test.ts.
|
||||
@@ -64,6 +94,8 @@ beforeEach(() => {
|
||||
eventBus.clear()
|
||||
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: match_transaction_invoice settlement account resolution', () => {
|
||||
@@ -147,16 +179,19 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledWith(
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'2026-05-12',
|
||||
undefined,
|
||||
'Test AB',
|
||||
12500,
|
||||
'1940',
|
||||
expect.objectContaining({
|
||||
source_type: 'invoice_paid',
|
||||
source_id: 'inv-1',
|
||||
entry_date: '2026-05-12',
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1940', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12500 }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
||||
@@ -272,16 +307,16 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(mockCreatePaymentEntry).toHaveBeenCalledWith(
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'2026-05-12',
|
||||
undefined,
|
||||
'Test AB',
|
||||
12500,
|
||||
'1930',
|
||||
expect.objectContaining({
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12500 }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -331,4 +366,170 @@ describe('commitPendingOperation: match_transaction_invoice settlement account r
|
||||
expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
|
||||
expect(mockCreateCashEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles a whole-krona payment of an öre-carrying invoice in full (3740 absorbs the residual)', async () => {
|
||||
// gnubok_feedback 2026-07-24: a bank tx exactly matching the invoice's
|
||||
// rounded "Att betala" was rejected as MATCH_AMOUNT_EXCEEDS_REMAINING
|
||||
// because this path called planInvoicePayment without absorbOreRounding.
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 12499.63,
|
||||
remaining_amount: 12499.63,
|
||||
paid_amount: 0,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'Test AB' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
|
||||
enqueue({ data: null, error: null }) // invoice_payments insert
|
||||
enqueue({ data: null, error: null }) // transactions update (link)
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12499.63 }),
|
||||
expect.objectContaining({ account_number: '3740' }),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({
|
||||
status: 'paid',
|
||||
paid_amount: 12499.63,
|
||||
remaining_amount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('converts a cross-currency payment into the invoice currency before recording it', async () => {
|
||||
// Parity with the dashboard/v1 routes: feeding the raw SEK amount into a
|
||||
// USD invoice corrupts the units of paid_amount / remaining_amount and
|
||||
// the invoice_payments row.
|
||||
mockFetchExchangeRate.mockResolvedValue({ currency: 'USD', rate: 10, date: '2026-05-12' })
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
amount_sek: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
currency: 'USD',
|
||||
exchange_rate: 10,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'US Inc' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // invoice CAS update
|
||||
enqueue({ data: null, error: null }) // invoice_payments insert
|
||||
enqueue({ data: null, error: null }) // transactions update (link)
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
expect(result.status).toBe('committed')
|
||||
// paid/remaining accumulate in INVOICE currency (1250 USD), not 12500 SEK.
|
||||
const invoiceUpdate = findCalls('invoices', 'update').at(-1)?.[0]
|
||||
expect(invoiceUpdate).toMatchObject({
|
||||
status: 'paid',
|
||||
paid_amount: 1250,
|
||||
remaining_amount: 0,
|
||||
})
|
||||
const paymentInsert = findCalls('invoice_payments', 'insert').at(-1)?.[0]
|
||||
expect(paymentInsert).toMatchObject({ amount: 1250, currency: 'USD', exchange_rate: 10 })
|
||||
})
|
||||
|
||||
it('rejects a cross-currency match when no exchange rate is available', async () => {
|
||||
mockFetchExchangeRate.mockResolvedValue(null)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
amount: 12500,
|
||||
amount_sek: 12500,
|
||||
currency: 'SEK',
|
||||
date: '2026-05-12',
|
||||
invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
cash_account_id: null,
|
||||
},
|
||||
error: null,
|
||||
}) // transaction fetch
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-2026001',
|
||||
status: 'sent',
|
||||
total: 1250,
|
||||
remaining_amount: 1250,
|
||||
paid_amount: 0,
|
||||
currency: 'USD',
|
||||
exchange_rate: 10,
|
||||
journal_entry_id: null,
|
||||
customer: { name: 'US Inc' },
|
||||
},
|
||||
error: null,
|
||||
}) // invoice fetch
|
||||
enqueue({ data: null, error: null }) // dispatcher pending_operations update
|
||||
|
||||
const op = makePendingOp({ params: { transaction_id: 'tx-1', invoice_id: 'inv-1' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
|
||||
// 400 (unlike 404/409 auto-rejects) surfaces as a plain failure; the op
|
||||
// row itself is released as 'rejected' with the error payload.
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
||||
import { cashPartialBlockReason, supplierCreditNoteNeedsJournalEntry } from '@/lib/bookkeeping/booking-mode'
|
||||
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
|
||||
import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
|
||||
@@ -2615,13 +2617,76 @@ async function commitMatchTransactionInvoice(
|
||||
return { error: 'Invoice is not in a matchable state', status: 409 }
|
||||
}
|
||||
|
||||
// FX resolution: parity with the dashboard and v1 match routes. paidAmount
|
||||
// MUST be denominated in the INVOICE's currency (the unit of
|
||||
// invoices.paid_amount / remaining_amount and invoice_payments.amount).
|
||||
// This path previously fed the raw bank amount straight in, which (a)
|
||||
// rejected exact whole-krona settlements of öre-carrying invoices and (b)
|
||||
// would corrupt the column units on a cross-currency match.
|
||||
const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK'
|
||||
if (
|
||||
txIsForeign &&
|
||||
transaction.amount_sek == null &&
|
||||
!(transaction.exchange_rate != null && transaction.exchange_rate > 0)
|
||||
) {
|
||||
return {
|
||||
error:
|
||||
getErrorEntry('MATCH_INVOICE_TX_FX_RATE_MISSING')?.message_sv ??
|
||||
'Transaktionen saknar valutakurs och SEK-belopp.',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
const txAbsSek =
|
||||
Math.round(
|
||||
resolveSekAmount(
|
||||
Math.abs(transaction.amount),
|
||||
transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null,
|
||||
transaction.currency,
|
||||
transaction.exchange_rate,
|
||||
) * 100,
|
||||
) / 100
|
||||
|
||||
let fx: { required: false } | { required: true; rate: number; paidInInvoiceCurrency: number } = {
|
||||
required: false,
|
||||
}
|
||||
if (transaction.currency !== invoice.currency) {
|
||||
let rate: number | null = null
|
||||
try {
|
||||
const rateInfo = await fetchExchangeRate(
|
||||
invoice.currency as Currency,
|
||||
new Date(transaction.date),
|
||||
supabase,
|
||||
)
|
||||
if (rateInfo && rateInfo.rate > 0) rate = rateInfo.rate
|
||||
} catch {
|
||||
rate = null
|
||||
}
|
||||
if (rate == null) {
|
||||
return {
|
||||
error:
|
||||
getErrorEntry('MATCH_INVOICE_FX_RATE_UNAVAILABLE')?.message_sv ??
|
||||
'Ingen valutakurs tillgänglig för betalningsdatumet.',
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
fx = {
|
||||
required: true,
|
||||
rate,
|
||||
paidInInvoiceCurrency: Math.round((txAbsSek / rate) * 10000) / 10000,
|
||||
}
|
||||
}
|
||||
const paidAmount = fx.required ? fx.paidInInvoiceCurrency : transaction.amount
|
||||
|
||||
// Overshoot guard + paid/remaining math: shared with the dashboard and v1
|
||||
// routes via planInvoicePayment. This agent/MCP path previously had NO guard,
|
||||
// so a 1500 payment on a 1000 invoice was silently accepted (paid_amount >
|
||||
// total, AR over-credited). Runs BEFORE the storno + JE below, so a rejected
|
||||
// match leaves the transaction untouched and never burns a voucher number.
|
||||
const paidAmount = transaction.amount
|
||||
const payment = planInvoicePayment(invoice, paidAmount)
|
||||
// total, AR over-credited). Pure-SEK settlements absorb sub-krona
|
||||
// öresavrundning (booked to 3740 by buildInvoicePaymentClearingLines) so a
|
||||
// whole-krona payment settles in full, exactly as on the other two routes.
|
||||
// Runs BEFORE the storno + JE below, so a rejected match leaves the
|
||||
// transaction untouched and never burns a voucher number.
|
||||
const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
|
||||
const payment = planInvoicePayment(invoice, paidAmount, { absorbOreRounding: pureSek })
|
||||
if (!payment.ok) {
|
||||
return {
|
||||
error:
|
||||
@@ -2696,11 +2761,60 @@ async function commitMatchTransactionInvoice(
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
} else {
|
||||
const je = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId, userId, invoice as Invoice, transaction.date, undefined, invoice.customer?.name, paidAmount,
|
||||
paymentAccount,
|
||||
)
|
||||
journalEntryId = je?.id ?? null
|
||||
// Clearing entry against 1510, built by the SAME shared helper the
|
||||
// dashboard and v1 routes use, so all three produce byte-identical
|
||||
// lines: bank leg = the actual SEK that hit the account, 1510 credited
|
||||
// at the invoice's booking rate, and a 3960/7960 FX-diff line (or a
|
||||
// 3740 öresavrundning line on pure SEK) making the verifikat balance.
|
||||
// The old createInvoicePaymentJournalEntry(paidAmount) shape could not
|
||||
// carry either residual, so öre-settled and cross-currency matches
|
||||
// left 1510 unclean. Failure semantics preserved: no fiscal period
|
||||
// still soft-fails to journalEntryId = null like the old builder did.
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
|
||||
if (!fiscalPeriodId) {
|
||||
log.warn('No open fiscal period found for payment date:', transaction.date)
|
||||
} else {
|
||||
const desc = invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`
|
||||
const { lines: clearingLines } = buildInvoicePaymentClearingLines(
|
||||
{
|
||||
amount: transaction.amount,
|
||||
amount_sek: transaction.amount_sek ?? null,
|
||||
currency: transaction.currency,
|
||||
exchange_rate: transaction.exchange_rate ?? null,
|
||||
},
|
||||
{
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate ?? null,
|
||||
remaining_amount: invoice.remaining_amount ?? null,
|
||||
total: invoice.total,
|
||||
paid_amount: invoice.paid_amount ?? null,
|
||||
},
|
||||
desc,
|
||||
fx.required ? fx.paidInInvoiceCurrency : undefined,
|
||||
paymentAccount,
|
||||
)
|
||||
// Re-propagate the invoice's default dimension bag onto every leg,
|
||||
// including the FX result lines, so a project's kursvinst/kursförlust
|
||||
// stays inside the project P&L: the shared line-builder is
|
||||
// dimension-agnostic.
|
||||
const defaultDimensions = coerceDimensionsBag(
|
||||
(invoice as { default_dimensions?: unknown }).default_dimensions,
|
||||
)
|
||||
if (defaultDimensions) {
|
||||
for (const line of clearingLines) line.dimensions = { ...defaultDimensions }
|
||||
}
|
||||
const je = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: desc,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines: clearingLines,
|
||||
})
|
||||
journalEntryId = je?.id ?? null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Recoverable: the dispatcher releases the op back to 'pending' and this
|
||||
@@ -2761,7 +2875,7 @@ async function commitMatchTransactionInvoice(
|
||||
payment_date: transaction.date,
|
||||
amount: paidAmount,
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
exchange_rate: fx.required ? fx.rate : invoice.exchange_rate,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
notes: null,
|
||||
|
||||
Reference in New Issue
Block a user