diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index 70978ec3..a9751da4 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -107,6 +107,12 @@ jobs: persist-credentials: false - uses: ./.github/actions/setup-core - run: npm run build + env: + # The Next.js build worker OOMs on the runner's default Node heap + # since 2026-08-19 (bundle growth crossed the default old-space + # ceiling; the first branch containing the whole day's merges was the + # first to hit it). Public-repo runners have 16 GB, give the build 8. + NODE_OPTIONS: --max-old-space-size=8192 test: # 897 unit test files (11,341 tests) take 3m31s in one process. Measured diff --git a/DECISIONS.md b/DECISIONS.md index 67b8252b..ba522693 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1084,3 +1084,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] Keep reversal allocation metadata limited to failures before any reversal header exists: later cleanup preserves a cancelled header with the allocated voucher number, so documenting it as an unused voucher gap would be false. [2026-08-19] Bank reconciliation: ignored transactions are excluded from bank_transaction_total/difference (surfaced as separate count+sum) rather than keeping the old "what the bank moved" semantics: the ignore flag's dominant real-world use is feed duplicates (which never moved money and never get a ledger leg), so including them made is_reconciled unreachable after a correct dupe cleanup (observed: permanent 78 867 kr differens on a fully booked EF account). descriptionsBridge now strips ALL whitespace before the prefix compare (collapse-only still misses a dropped space); safe because char-filtering preserves prefix relations and the compare stays inside a (date,ore) bucket. [2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount. +[2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch. diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index c5a0a36e..e7dd86bd 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -67,6 +67,7 @@ import type { } from '@/lib/import/types' import { applyVatTreatmentReview, + applyVatTreatmentReviewAll, enrichChangedAccountMappingWithVat, enrichAccountMappingsWithVat, } from '@/lib/import/account-vat-treatment' @@ -941,6 +942,10 @@ function SIEImportWizard() { setMappings((prev) => applyVatTreatmentReview(prev, sourceAccount, treatment, rate)) }, []) + const handleConfirmAllVatTreatments = useCallback(() => { + setMappings((prev) => applyVatTreatmentReviewAll(prev)) + }, []) + const confirmVatReview = useCallback(() => { if (mappings.some((mapping) => mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed @@ -1140,6 +1145,7 @@ function SIEImportWizard() { {step === 'mapping' && ( )} {step === 'review' && preview && ( diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 06235198..852451ac 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -241,6 +241,7 @@ import ArcimMigrationTheater from '@/components/extensions/general/ArcimMigratio import TheaterCanvas from '@/components/import/TheaterCanvas' import { applyVatTreatmentReview, + applyVatTreatmentReviewAll, enrichChangedAccountMappingWithVat, enrichAccountMappingsWithVat, } from '@/lib/import/account-vat-treatment' @@ -984,6 +985,7 @@ function MappingStep({ errorDetails, onMappingChange, onVatTreatmentChange, + onConfirmAllVatTreatments, onContinue, onBack, }: { @@ -997,6 +999,7 @@ function MappingStep({ treatment: AccountVatTreatment | null, rate: number | null, ) => void + onConfirmAllVatTreatments: () => void onContinue: () => void onBack: () => void }) { @@ -1040,6 +1043,7 @@ function MappingStep({ basAccounts={sieData.basAccounts} onMappingChange={onMappingChange} onVatTreatmentChange={onVatTreatmentChange} + onConfirmAllVatTreatments={onConfirmAllVatTreatments} onContinue={onContinue} onBack={onBack} /> @@ -2644,6 +2648,13 @@ export default function ArcimMigrationWorkspace({ } : null) }, []) + const handleConfirmAllVatTreatments = useCallback(() => { + setSieData(prev => prev ? { + ...prev, + mappings: applyVatTreatmentReviewAll(prev.mappings), + } : null) + }, []) + const handleMappingContinue = useCallback(() => { setStep('options') }, []) @@ -2894,6 +2905,7 @@ export default function ArcimMigrationWorkspace({ errorDetails={errorDetails} onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange} + onConfirmAllVatTreatments={handleConfirmAllVatTreatments} onContinue={handleMappingContinue} onBack={() => setStep('preview')} /> diff --git a/components/import/AccountMappingStep.tsx b/components/import/AccountMappingStep.tsx index d5af17a4..d4a10683 100644 --- a/components/import/AccountMappingStep.tsx +++ b/components/import/AccountMappingStep.tsx @@ -53,6 +53,8 @@ interface AccountMappingStepProps { treatment: AccountVatTreatment | null, rate: number | null, ) => void + /** Accept the suggested VAT treatment for every unreviewed row at once. */ + onConfirmAllVatTreatments: () => void onContinue: () => void onBack: () => void } @@ -66,6 +68,7 @@ export default function AccountMappingStep({ basAccounts, onMappingChange, onVatTreatmentChange, + onConfirmAllVatTreatments, onContinue, onBack, }: AccountMappingStepProps) { @@ -443,14 +446,26 @@ export default function AccountMappingStep({ - +
+ {stats.vatReview > 0 && stats.unmapped === 0 && ( + + )} + +
) diff --git a/lib/import/__tests__/account-vat-treatment.test.ts b/lib/import/__tests__/account-vat-treatment.test.ts index f3e1fb1e..4778689b 100644 --- a/lib/import/__tests__/account-vat-treatment.test.ts +++ b/lib/import/__tests__/account-vat-treatment.test.ts @@ -3,6 +3,7 @@ import { applyVatTreatmentReview, enrichChangedAccountMappingWithVat, enrichAccountMappingsWithVat, + applyVatTreatmentReviewAll, } from '../account-vat-treatment' import type { AccountMapping } from '../types' @@ -145,3 +146,40 @@ describe('applyVatTreatmentReview', () => { }) }) }) + +describe('applyVatTreatmentReviewAll', () => { + it('marks every unreviewed row reviewed and keeps its suggested default', () => { + const mappings = [ + { + sourceAccount: '3001', sourceName: 'Försäljning', targetAccount: '3001', targetName: 'Försäljning', + confidence: 1, matchType: 'exact', isOverride: false, + defaultVatTreatment: 'sales_25', defaultVatRate: 25, + vatTreatmentSuggested: true, vatTreatmentReviewed: false, requiresVatTreatmentReview: true, + }, + { + sourceAccount: '4010', sourceName: 'Inköp', targetAccount: '4010', targetName: 'Inköp', + confidence: 1, matchType: 'exact', isOverride: false, + defaultVatTreatment: null, defaultVatRate: null, + vatTreatmentSuggested: false, vatTreatmentReviewed: false, requiresVatTreatmentReview: true, + }, + { + sourceAccount: '1930', sourceName: 'Bank', targetAccount: '1930', targetName: 'Bank', + confidence: 1, matchType: 'exact', isOverride: false, + defaultVatTreatment: null, defaultVatRate: null, + vatTreatmentSuggested: false, vatTreatmentReviewed: true, requiresVatTreatmentReview: false, + }, + ] as never[] + + const result = applyVatTreatmentReviewAll(mappings) + + // Both review rows confirmed in one action, defaults untouched. + expect(result[0]).toMatchObject({ vatTreatmentReviewed: true, defaultVatTreatment: 'sales_25', defaultVatRate: 25 }) + expect(result[1]).toMatchObject({ vatTreatmentReviewed: true, defaultVatTreatment: null }) + // Already-reviewed rows pass through by reference. + expect(result[2]).toBe(mappings[2]) + // No row is left gating the wizard's Continue button. + expect(result.filter((m: { requiresVatTreatmentReview?: boolean; vatTreatmentReviewed?: boolean }) => + m.requiresVatTreatmentReview && !m.vatTreatmentReviewed + )).toHaveLength(0) + }) +}) diff --git a/lib/import/account-vat-treatment.ts b/lib/import/account-vat-treatment.ts index bd2ccace..febf0245 100644 --- a/lib/import/account-vat-treatment.ts +++ b/lib/import/account-vat-treatment.ts @@ -86,3 +86,23 @@ export function enrichChangedAccountMappingWithVat( : mapping ) } + +/** + * Accept the suggested VAT treatment for every mapping still awaiting review, + * in one action. Exactly the per-row "Bekräfta" semantics batched: each row + * keeps its current suggested default (or null when there is none) and is + * marked reviewed. Added because a Fortnox chart routinely puts 70+ class 3/4 + * accounts behind the review gate, and clicking them one by one across + * paginated pages was an observed migration dead end (2026-08-18). + */ +export function applyVatTreatmentReviewAll(mappings: AccountMapping[]): AccountMapping[] { + return mappings.map((mapping) => + mapping.requiresVatTreatmentReview && !mapping.vatTreatmentReviewed + ? { + ...mapping, + vatTreatmentSuggested: false, + vatTreatmentReviewed: true, + } + : mapping + ) +} diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index b16703d2..4b18d534 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -1,3 +1,4 @@ +import { roundOre } from '@/lib/money' import { Document, Page, @@ -704,7 +705,7 @@ export function resolvePdfPaidState( const remainingAmount = invoice.status === 'paid' ? 0 - : invoice.remaining_amount ?? Math.max(0, Math.round((amountToPay - paidAmount) * 100) / 100) + : invoice.remaining_amount ?? Math.max(0, roundOre(amountToPay - paidAmount)) return { kind: invoice.status, paidAmount, diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index 25733c8e..f2d9aa0e 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { roundOre } from '@/lib/money' import type { Transaction, ReconciliationMethod } from '@/types' import { eventBus } from '@/lib/events/bus' import { logMatchEvent } from '@/lib/invoices/match-log' @@ -896,7 +897,7 @@ export async function getReconciliationStatus( return { currency, bank_transaction_total: Math.round(bankTotal * 100) / 100, - ignored_transaction_total: Math.round(ignoredTotal * 100) / 100, + ignored_transaction_total: roundOre(ignoredTotal), ignored_transaction_count: ignoredTx.length, gl_1930_balance: Math.round(glBalance * 100) / 100, gl_1930_period_movement: Math.round(glPeriodMovement * 100) / 100, diff --git a/messages/en.json b/messages/en.json index 4093c0bf..bc95cc46 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5226,6 +5226,7 @@ "vat_treatment_rental_voluntary": "Voluntary VAT on rental income (box 08)", "vat_rate_label": "VAT rate", "vat_rate_none": "None", + "vat_review_confirm_all": "Confirm all suggested ({count})", "vat_review_filter": "{count} VAT treatments to review", "vat_treatment_column": "VAT treatment", "vat_treatment_confirm": "Confirm" diff --git a/messages/sv.json b/messages/sv.json index 7ed6a69b..3e1ec04d 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5226,6 +5226,7 @@ "vat_treatment_rental_voluntary": "Frivillig moms på uthyrning (ruta 08)", "vat_rate_label": "Momssats", "vat_rate_none": "Ingen", + "vat_review_confirm_all": "Bekräfta alla föreslagna ({count})", "vat_review_filter": "{count} momskoder att granska", "vat_treatment_column": "Momskod", "vat_treatment_confirm": "Bekräfta"