fix(import): bulk-confirm the VAT-treatment review gate in account mapping (#1723)
* fix(import): bulk-confirm the VAT-treatment review gate in account mapping A Fortnox chart routinely puts 70+ class 3/4 accounts behind the vat-treatment review gate, and the only way through was one Bekräfta click per row across paginated 50-row pages. A live migration (2026-08-18) died exactly there, stuck at 50 kvar with Continue disabled and no way to see why. One outline button next to Continue now accepts the suggested default for every remaining row, with the exact semantics of the per-row button batched (defaults kept, rows marked reviewed). Wired in both the import wizard and the Arcim migration workspace. Strings in sv+en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(guards): two naive-ore-rounds that stacked past the ratchet baseline #1700 and #1705 each added one Math.round(x*100)/100 and each passed CI alone against baseline 630; the first branch containing both trips the ratchet at 631. Convert both to roundOre (629, below baseline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: place the roundOre import on its own line The previous commit inserted it inside a multi-line import block, breaking parsing in pdf-template.tsx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: give next build an explicit 8 GB heap The build worker OOMs on the runner's default Node heap since the bundle crossed the default old-space ceiling (first branch containing all of 2026-08-19's merges). Public-repo runners have 16 GB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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:
@@ -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
|
||||
|
||||
@@ -1084,3 +1084,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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' && (
|
||||
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
||||
onMappingChange={handleMappingChange} onVatTreatmentChange={handleVatTreatmentChange}
|
||||
onConfirmAllVatTreatments={handleConfirmAllVatTreatments}
|
||||
onContinue={confirmVatReview} onBack={goBack} />
|
||||
)}
|
||||
{step === 'review' && preview && (
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
<Button variant="outline" className="min-h-11" onClick={onBack}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
|
||||
{canContinue
|
||||
? 'Fortsätt till granskning'
|
||||
: stats.unmapped > 0
|
||||
? `${stats.unmapped} konton saknar mappning`
|
||||
: `${stats.vatReview} momskoder återstår att bekräfta`}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{stats.vatReview > 0 && stats.unmapped === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11"
|
||||
onClick={onConfirmAllVatTreatments}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
{t('vat_review_confirm_all', { count: stats.vatReview })}
|
||||
</Button>
|
||||
)}
|
||||
<Button className="min-h-11" onClick={onContinue} disabled={!canContinue}>
|
||||
{canContinue
|
||||
? 'Fortsätt till granskning'
|
||||
: stats.unmapped > 0
|
||||
? `${stats.unmapped} konton saknar mappning`
|
||||
: `${stats.vatReview} momskoder återstår att bekräfta`}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user