fix(bookkeeping): book a negative line-pattern rounding diff on 3740 opposite the business side (#1898) (#1994)
buildMultiLineMappingResult booked Math.abs(roundingDiff) on the business side regardless of sign, so a learned line_pattern whose ratios over-allocate (three 0.3334 ratios on 100.00 kr = 100.02, diff -0.02) produced an entry off by 2x|diff|. commit_journal_entry rejected it, so the user saw a failed confirm and, since #1894, an unbalanced prefill. The 3740 leg now lands on the business side for a positive diff (under-allocation, unchanged) and on the opposite side for a negative diff (over-allocation), flipped after the mirror. computeProposalLines gets the identical rule in the same change to keep the byte-parity contract, and a 5000-amount sweep test pins engine and proposal together. Also reachable with normalized ratios: 50/50 on 100.03 kr rounds to 50.02 + 50.02. Closes #1898 Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 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:
@@ -1298,6 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
|
||||
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
|
||||
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
|
||||
[2026-08-27] #1898 line-pattern 3740 rounding leg now lands opposite the business side when the ratios over-allocate (negative diff) and stays on the business side when they under-allocate (positive diff), in buildMultiLineMappingResult AND computeProposalLines in the same change, superseding the 2026-08-25 note that kept business-side placement for both signs: that note assumed the imbalance never reached a user, but since #1894 the prefilled form carries it and the confirm fails with JournalEntryNotBalancedError. Reachable with normalized ratios too (50/50 on 100.03 kr rounds to 50.02 + 50.02), not only with un-normalized patterns. The flip is applied AFTER the mirror on both sides (the engine's businessSide is already mirrored via side(), the proposal's raw side is mirrored first), no new Math.round(x*100)/100 was written (the existing rounded diff is reused, naiveOreRound baseline unchanged), positive-diff bookings are byte-identical to before, and parity is executable: proposal-lines.test.ts sweeps 0.01..50.00 kr against the engine.
|
||||
[2026-08-27] /mfa/enroll leave() always hard-navigates (window.location.assign) instead of router.push+router.refresh: enrolment raises the session to aal2 which middleware only re-evaluates on a document request, and the push/refresh pair raced leaving the user on the QR screen with 2FA already on (#1948); same pattern the file already used for /api/ destinations and /mfa/verify uses for its invite and route-handler paths. The E2E regression cover (spectest/tests/mfa.ts) lands with the spectest suite separately.
|
||||
[2026-08-27] Brand domains gate signup server-side (brands.signup_mode + brand_signup_allowlist), not in the register page: the browser used to call supabase.auth.signUp directly, so any client-side host check would be cosmetic. Email signup moved to POST /api/auth/signup on ALL hosts (byte-identical GoTrue call for open hosts); BankID gates in /bankid/complete; Google gates via the dashboard layout's brand-domain bounce (the account exists after OAuth, but gets no branded experience). Company invites bypass the allowlist because the invite is the authorization. Allowlisted signups' companies attach to the brand's byrå team via create_company_for_brand_signup (allowlist entry = the byrå's standing WL-15 authorization, recorded by an owner/admin); without the attach, WL-01 would home the company on the canonical domain, invisible on the very domain the user signed up on. Rejected a Supabase before-user-created hook: it does not reliably see the originating host and adds dashboard config coupling.
|
||||
[2026-08-27] New `tool-pg` vitest project: MCP tools driven through a REAL supabase-js client against a REAL PostgREST (tests/tool-pg/, scripts/tool-pg/reset.sh, `npm run tools:pg:reset` + `npm run test:tools`, plus a tool-pg CI job). NOT a duplicate of pg-real: that project holds a `pg` Pool and writes SQL, which structurally cannot see the half of a tool that PostgREST resolves at request time (the `.select()` column strings, the resource embeds, the `or=(...)` grammar, `.contains()` operand types). Before this, all 100 files in extensions/general/mcp-server/__tests__ faked supabase and query-journal.test.ts deferred its query chain to "the live MCP smoke test", which does not exist in CI: the PostgREST grammar of 157 tools was gated by nothing. Three findings worth keeping. (1) supabase-js hard-codes a `/rest/v1` prefix that a bare PostgREST does not serve, so the first version of the harness 404'd all 55 sweep queries, the tools reported the empty response as "Database error: undefined", and the suite passed GREEN while exercising nothing; fixed with a URL-rewriting `global.fetch` in createToolPgClient, and a permanent self-test now injects a bad column and asserts the harness detects 42703, so a green sweep means something. (2) Errors are captured at the TRANSPORT, not from the thrown Error: the tools wrap failures in their own prose and lose the payload, so a real 42703 arrives as an unclassifiable string. (3) The reset recreates the CONTAINER rather than dropping schemas: `storage` is owned by supabase_storage_admin so `DROP SCHEMA storage` fails as postgres, and dropping only `public` leaves the storage RLS policies migration 20240101000024 creates unconditionally, aborting the next replay partway and leaving a half-migrated database that looks like a migration bug. CI runs PostgREST via `docker run --network host` rather than a service container, because service containers on a non-containerized job are reachable from the runner but not from each other by name. Current coverage is honest and partial: 74 read tools, 87 real requests, 0 malformed queries, 4 failures all 22P02 from the empty argument set. Per-tool argument fixtures are what deepen it, and the harness is the thing that makes writing them worthwhile.
|
||||
|
||||
@@ -1110,6 +1110,116 @@ describe('dimensions propagation (PR7)', () => {
|
||||
|
||||
// ── learning-loop repair (issue #865) ────────────────────────
|
||||
|
||||
describe('line-pattern rounding diff on 3740 (issue #1898)', () => {
|
||||
const expenseTriple = (ratio: number): LinePatternEntry[] => [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio },
|
||||
]
|
||||
|
||||
function entrySums(
|
||||
tx: Parameters<typeof buildTransactionEntryLines>[0],
|
||||
result: Parameters<typeof buildTransactionEntryLines>[1],
|
||||
) {
|
||||
const lines = buildTransactionEntryLines(tx, result)
|
||||
return {
|
||||
debits: roundOre(lines.reduce((s, l) => s + l.debit_amount, 0)),
|
||||
credits: roundOre(lines.reduce((s, l) => s + l.credit_amount, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
it('books an over-allocating pattern rounding diff on 3740 opposite the business side', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.3334),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 3 x 33.34 = 100.02 over-allocates by 0.02: 3740 must offset on the credit side
|
||||
for (const account of ['6110', '6212', '6991']) {
|
||||
expect(result.vat_lines.find((l) => l.account_number === account)?.debit_amount).toBe(33.34)
|
||||
}
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.credit_amount).toBe(0.02)
|
||||
expect(rounding?.debit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100.02)
|
||||
expect(credits).toBe(100.02)
|
||||
})
|
||||
|
||||
it('balances a normalized 50/50 pattern on an odd-ore amount', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
],
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100.03 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 50.015 rounds to 50.02 twice: ratios that sum to exactly 1 still over-allocate
|
||||
expect(result.vat_lines.find((l) => l.account_number === '6110')?.debit_amount).toBe(50.02)
|
||||
expect(result.vat_lines.find((l) => l.account_number === '6212')?.debit_amount).toBe(50.02)
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.credit_amount).toBe(0.01)
|
||||
expect(rounding?.debit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(credits)
|
||||
})
|
||||
|
||||
it('keeps an under-allocating diff on the business side', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.333),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -100 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
// 3 x 33.30 = 99.90 under-allocates by 0.10: unchanged business-side placement
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.debit_amount).toBe(0.1)
|
||||
expect(rounding?.credit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100)
|
||||
expect(credits).toBe(100)
|
||||
})
|
||||
|
||||
it('mirrors the over-allocation rounding leg on sign mismatch', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: expenseTriple(0.3334),
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: 100 }) // refund of a three-way expense
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.direction_mismatch).toBe(true)
|
||||
for (const account of ['6110', '6212', '6991']) {
|
||||
expect(result.vat_lines.find((l) => l.account_number === account)?.credit_amount).toBe(33.34)
|
||||
}
|
||||
// Mirrored business side is credit, so the over-allocation offset lands on debit
|
||||
const rounding = result.vat_lines.find((l) => l.account_number === '3740')
|
||||
expect(rounding?.debit_amount).toBe(0.02)
|
||||
expect(rounding?.credit_amount).toBe(0)
|
||||
const { debits, credits } = entrySums(tx, result)
|
||||
expect(debits).toBe(100.02)
|
||||
expect(credits).toBe(100.02)
|
||||
})
|
||||
})
|
||||
|
||||
describe('learning-loop repair (issue #865)', () => {
|
||||
/** Queue-based mock that records insert payloads per table (see PR7 block). */
|
||||
function createCapturingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeProposalLines, proposalLinesToFormLines, resolveTemplateAccountsForEntity } from '@/lib/bookkeeping/proposal-lines'
|
||||
import type { ProposalLine } from '@/lib/bookkeeping/proposal-lines'
|
||||
import { buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { makeCategorizationTemplate, makeTransaction } from '@/tests/helpers'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { LinePatternEntry } from '@/types'
|
||||
|
||||
@@ -387,6 +389,75 @@ describe('computeProposalLines', () => {
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('books an over-allocating diff on 3740 opposite the business side (#1898)', () => {
|
||||
const over: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -100, linePattern: over })
|
||||
// 3 x 33.34 = 100.02 over-allocates by 0.02: 3740 offsets on the credit side
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '3740', amount: 0.02 })
|
||||
expect(sumSide(lines, 'debet')).toBe(100.02)
|
||||
expect(sumSide(lines, 'kredit')).toBe(100.02)
|
||||
})
|
||||
|
||||
it('balances a normalized 50/50 pattern on an odd-ore amount (#1898)', () => {
|
||||
const half: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: -100.03, linePattern: half })
|
||||
// 50.015 rounds to 50.02 twice: ratios that sum to exactly 1 still over-allocate
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '6110', amount: 50.02 })
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '3740', amount: 0.01 })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('mirrors the over-allocation rounding leg on a refund (#1898)', () => {
|
||||
const over: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
{ account: '6991', type: 'business', side: 'debit', ratio: 0.3334 },
|
||||
]
|
||||
const lines = computeProposalLines({ amount: 100, linePattern: over })
|
||||
// Mirrored business side is kredit, so the over-allocation offset lands on debet
|
||||
expect(lines).toContainEqual({ side: 'kredit', account: '6110', amount: 33.34 })
|
||||
expect(lines).toContainEqual({ side: 'debet', account: '3740', amount: 0.02 })
|
||||
expect(sumSide(lines, 'debet')).toBe(sumSide(lines, 'kredit'))
|
||||
})
|
||||
|
||||
it('keeps the 3740 leg in byte parity with the engine across 0.01..50.00 kr (#1898)', () => {
|
||||
const half: LinePatternEntry[] = [
|
||||
{ account: '6110', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
{ account: '6212', type: 'business', side: 'debit', ratio: 0.5 },
|
||||
]
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6110',
|
||||
credit_account: '1930',
|
||||
line_pattern: half,
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
for (let ore = 1; ore <= 5000; ore++) {
|
||||
const amount = -(ore / 100)
|
||||
const tx = makeTransaction({ amount })
|
||||
const engine = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
const proposal = computeProposalLines({ amount, linePattern: half })
|
||||
const engineRounding = engine.vat_lines.find(l => l.account_number === '3740')
|
||||
const proposalRounding = proposal.find(l => l.account === '3740')
|
||||
if (engineRounding) {
|
||||
expect(proposalRounding, `amount ${amount}`).toEqual({
|
||||
side: engineRounding.debit_amount > 0 ? 'debet' : 'kredit',
|
||||
account: '3740',
|
||||
amount: engineRounding.debit_amount || engineRounding.credit_amount,
|
||||
})
|
||||
} else {
|
||||
expect(proposalRounding, `amount ${amount}`).toBeUndefined()
|
||||
}
|
||||
expect(sumSide(proposal, 'debet'), `amount ${amount}`).toBe(sumSide(proposal, 'kredit'))
|
||||
}
|
||||
})
|
||||
|
||||
it('handles income patterns with the settlement on the debit side', () => {
|
||||
const incomePattern: LinePatternEntry[] = [
|
||||
{ account: '2611', type: 'vat', side: 'credit', vat_rate: 0.25 },
|
||||
|
||||
@@ -624,7 +624,8 @@ function buildLegacyMismatchResult(
|
||||
* Build a MappingResult from a multi-line counterparty template pattern.
|
||||
*
|
||||
* VAT is computed from rate (exact), business/tax from ratio against non-VAT subtotal.
|
||||
* Rounding difference goes to 3740 (Öresutjämning).
|
||||
* Rounding difference goes to 3740 (Öresutjämning): on the business side when
|
||||
* the ratios under-allocate, on the opposite side when they over-allocate (#1898).
|
||||
* Settlement line always equals the exact transaction amount.
|
||||
*/
|
||||
function buildMultiLineMappingResult(
|
||||
@@ -688,12 +689,18 @@ function buildMultiLineMappingResult(
|
||||
const totalAllocated = Math.round((totalVat + nonVatAllocated) * 100) / 100
|
||||
const roundingDiff = Math.round((absAmount - totalAllocated) * 100) / 100
|
||||
if (roundingDiff !== 0) {
|
||||
// Determine the side for the rounding line (same side as business lines)
|
||||
// A positive diff means the ratios under-allocated: 3740 fills the gap on
|
||||
// the business side. A negative diff means they over-allocated (three
|
||||
// 0.3334 ratios on 100.00 kr give 3 x 33.34 = 100.02): 3740 offsets on
|
||||
// the OPPOSITE side so the non-settlement lines net to absAmount (#1898).
|
||||
// businessSide is already mirror-applied via side(), so flip after it.
|
||||
const businessSide = side(pattern.find(e => e.type === 'business')?.side ?? 'credit')
|
||||
const roundingSide: 'debit' | 'credit' =
|
||||
roundingDiff > 0 ? businessSide : (businessSide === 'debit' ? 'credit' : 'debit')
|
||||
allLines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: businessSide === 'debit' ? Math.abs(roundingDiff) : 0,
|
||||
credit_amount: businessSide === 'credit' ? Math.abs(roundingDiff) : 0,
|
||||
debit_amount: roundingSide === 'debit' ? Math.abs(roundingDiff) : 0,
|
||||
credit_amount: roundingSide === 'credit' ? Math.abs(roundingDiff) : 0,
|
||||
description: 'Öresutjämning',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
* gross = 14 mod 28 ore and the entry goes off by 1 ore), why the fiktiv-moms
|
||||
* pair uses the engine's plain rounding (roundOre's EPSILON nudge diverges at
|
||||
* exact-half floats like 8.62 * 0.25), and why sign-mismatched counterparty
|
||||
* matches are mirrored exactly as the server mirrors them.
|
||||
* matches are mirrored exactly as the server mirrors them. The 3740 rounding
|
||||
* leg follows the engine's sign rule too: business side when the ratios
|
||||
* under-allocate, opposite side when they over-allocate (#1898).
|
||||
*
|
||||
* The resulting booking still goes through JournalEntryForm's normal manual
|
||||
* validation and the bookkeeping engine: nothing here writes to the ledger.
|
||||
@@ -251,8 +253,15 @@ export function computeProposalLines(input: ProposalLinesInput): ProposalLine[]
|
||||
const totalAllocated = engineRound(totalVat + allocated)
|
||||
const diff = engineRound(absAmount - totalAllocated)
|
||||
if (diff !== 0) {
|
||||
// ENGINE PARITY (#1898, buildMultiLineMappingResult step 4): a positive
|
||||
// diff (under-allocation) lands on the mirrored business side, a
|
||||
// negative diff (over-allocation) on the opposite side. The flip is
|
||||
// applied AFTER the mirror, exactly as the engine does it.
|
||||
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
result.push({ side: side(businessSide), account: '3740', amount: Math.abs(diff) })
|
||||
const effectiveBusinessSide = side(businessSide)
|
||||
const roundingSide: 'debet' | 'kredit' =
|
||||
diff > 0 ? effectiveBusinessSide : (effectiveBusinessSide === 'debet' ? 'kredit' : 'debet')
|
||||
result.push({ side: roundingSide, account: '3740', amount: Math.abs(diff) })
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user