fix(providers): surface migration step errors; INK2 SRU 7104; non-modal invoice dialog (#1465)

* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export

New mileage_trips table (RLS, booked-delete trigger per BFL retention),
lib/mileage service reusing the payroll schablon rates, /api/mileage routes
(trips CRUD, period booking to 7331, salary-run push, körjournal CSV),
Körjournal dashboard page + nav, and three staged MCP tools (search-only
catalog). Trips book as one verifikat per period via the engine; salary
path inserts mileage_taxfree line items. mileage_trips classified in the
full-archive export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mileage): use shared roundOre helper per tightened ratchet baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): pending_operations op-type migration + Swedish review findings

- New migration pair adds log_mileage_trip/book_mileage_period to the
  pending_operations operation_type CHECK (pg-real audit).
- bookMileagePeriod refuses a period spanning several employees and names
  the employee in the verifikationstext when scoped (BFL motpart).
- vehicle_registration required for förmånsbil trips (schema, service,
  MCP staging, UI surfaces the field).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): claim-first booking, CSV injection guard and driver column

- bookMileagePeriod claims trips (draft to booked CAS) before creating the
  verifikat, so a concurrent second booking loses the race instead of
  double-booking; claim reverts if verifikat creation fails.
- Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a
  Förare column naming the employee per trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening

- Copying a round trip no longer re-doubles the stored distance.
- pushMileageToSalaryRun claims trips before inserting line items (retry can
  no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races.
- Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration
  20260807113215): only claim/link/revert transitions and notes edits pass.
- Cross-year periods rejected (schablon rates are per calendar year); payroll
  config year read from the date string, not TZ-dependent getFullYear().
- MCP staged bookings freeze the previewed trip set (trip_ids in params) and
  the commit fails on drift; validation errors return 400, not 500.
- PATCH enforces the förmånsbil regnr rule on the effective row; export
  validates dates before they reach the Content-Disposition header; employee_id
  is verified company-scoped on trip creation; stale orphaned claims released.
- UI: fetch flags reset in finally; ICU plural for draft summary; distance
  stored at the column's 1-decimal precision.
- Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift,
  cross-year and update-trigger pg cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): revert-to-draft must clear salary_run_id at the trigger level

New migration 20260807114924 replaces the booked-immutability function: a
booked -> draft revert now rejects rows keeping salary_run_id, closing the
DB-level double-pay path CodeRabbit flagged. pg test pins both directions;
the CLAIM_LOST unit test now asserts the revert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): company-scope employee_id on PATCH (Superagent P2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mileage): valid v4 uuid in cross-company employee PATCH test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): surface migration step errors instead of silent empty syncs

A Visma company without the API module activated (403 ErrorCode 4002,
"No access to module: api_standard") failed every provider call during
migration, yet the wizard reported success with zero rows and mapped the
403 to "reconnect", which loops forever since OAuth succeeds against
Visma's shared identity server. A real user burned time re-syncing and
reconnecting, then filed the config issue as a bug.

- New PROVIDER_API_MODULE_INACTIVE code; classifyProviderError reads the
  error body and recognizes the module error before the 403 to
  AUTH_EXPIRED mapping. Registry entry carries the remediation in
  Swedish and English (activate the API under Appar och tillagg, paid
  add-on on smaller plans, clear standardforetag, SIE fallback).
- Orchestrator: connection-level failures (auth expired, license
  missing, module inactive) rethrow and abort the doomed run so /migrate
  answers with the typed code; other step failures stay non-fatal but
  land on results.stepErrors instead of only in server logs.
- /preview fails fast on the two subscription codes so the user reads
  the remediation at connect time, before any sync.
- Wizard: preview treats the new code like the Fortnox license case
  (CTA + SIE fallback); the result step renders error cards per cause
  and says "Migrering delvis genomford" instead of "Allt ar uppdaterat";
  the completion toast is honest on partial failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ink2): SRU field 1.1 is 7104, not 7113 (Skatteverket rejects 7113)

The INK2 huvudblankett code for 1.1 Overskott av naringsverksamhet is
7104 per Skatteverket's official 2025P4 faltkoder (INK2_SKV2002-33-01-24-04).
We emitted 7113, which does not exist on INK2, so filoverforing rejected
every profitable company's BLANKETTER.SRU with 'UPPGIFT 7113 ar inte ett
giltigt postnamn' (reported by a user for FY 2024-10-07..2025-12-31).
Underskott (7114) was already correct.

The wrong code originated in the swedish-sru-filing skill reference;
fixed there too and regenerated the atom seed. All other emitted
INK2/INK2R/INK2S codes verified against the official 2025P4 lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): keep the AI chat usable over the new-invoice dialog

The new-invoice dialog was a modal Radix dialog: modal mode sets body
pointer-events: none, aria-hidden on body siblings, and a focus trap, so
the agent sheet (z-60, painted above the dialog) was visible but dead:
clicks swallowed, input unfocusable, and all three dismiss paths
preventDefaulted, leaving no way out except the header X.

Now non-modal: page modality is restored by hand instead. A new
DialogVeil primitive supplies the backdrop (Radix renders no overlay in
non-modal mode) at z-40, under dialog content (z-50) and the agent sheet
(z-60), and inert on #dash-shell blocks pointer, keyboard, and AT access
to the page behind while the sheet (a body-level sibling) stays live.
The lazy-load fallback dialog on /invoices gets the same treatment so a
hung or 404'd chunk cannot dead-lock the route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-08 16:04:58 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c7dd78b0a3
commit 39f4ecdad4
22 changed files with 18139 additions and 31 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ Reserved for post names. **Forbidden in all string data values.**
#NAMN Exempelbolaget AB
#UPPGIFT 7011 20240101
#UPPGIFT 7012 20241231
#UPPGIFT 7113 90000
#UPPGIFT 7104 90000
#BLANKETTSLUT
#BLANKETT INK2R-2024P4
#IDENTITET 165590001234 20250401 100001
@@ -22,14 +22,14 @@ Complete field code (fältkod) tables for INK2, INK2R, and INK2S blankett types,
|---|---|---|
| 7011 | N/A | Räkenskapsår fr.o.m. (YYYYMMDD) |
| 7012 | N/A | Räkenskapsår t.o.m. (YYYYMMDD) |
| 7113 | 1.1 | Överskott av näringsverksamhet |
| 7104 | 1.1 | Överskott av näringsverksamhet |
| 7114 | 1.2 | Underskott av näringsverksamhet |
| 7132 | 1.4 | Underlag för särskild löneskatt på pensionskostnader |
| 7133 | 1.5 | Negativt underlag särskild löneskatt |
| 7153 | 1.6a | Avkastningsskatt 15% |
| 7155 | 1.7a | Avkastningsskatt 30% |
Fields 7113/7114 correspond directly to INK2S fields 8020/8021.
Fields 7104/7114 correspond directly to INK2S fields 8020/8021. (Verified against Skatteverket's official 2025P4 field list: 1.1 is 7104, NOT 7113; 7113 does not exist on INK2 and Skatteverket rejects it with "är inte ett giltigt postnamn".)
---
@@ -168,7 +168,7 @@ Fields 7113/7114 correspond directly to INK2S fields 8020/8021.
| 7763 | 4.14a | Outnyttjat underskott från föregående beskattningsår |
| 7664 | 4.14b | Reduktion av underskott (beloppsspärr/ackord) |
| 7670 | 4.14c | Reduktion pga koncernbidragsspärr/fusionsspärr |
| 8020 | 4.15 | Överskott → överförs till punkt 1.1 (INK2 field 7113) |
| 8020 | 4.15 | Överskott → överförs till punkt 1.1 (INK2 field 7104) |
| 8021 | 4.16 | Underskott → överförs till punkt 1.2 (INK2 field 7114) |
| 7770 | 4.20 | Lån från aktieägare (fysisk person) vid beskattningsårets utgång |
+2
View File
@@ -826,6 +826,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] Review-workflow triage on the Bucket A branch (13 confirmed findings): fixed 10, incl. the branch-killing one (setActiveCompany's cookie write throws in Server Component render, so the /select-company auto-forward silently never fired: the cookie set is now best-effort because the gnubok-company-id cookie is write-only compat nothing reads). Batch "Ingen moms" now goes over the wire as 'exempt' instead of collapsing to undefined, which had an explicit no-VAT choice booking the derived 25%; the same pre-existing collapse in QuickReviewDialog/CategoryExpandedDialog is left for a follow-up. Skipped by choice: generalizing AiFilledIndicator for history provenance (the note's copy already names the source) and converting BulkBookInboxDialog's hardcoded-Swedish option lists to i18n (whole-file migration, not this branch's divergence). Monthly momsdeklaration default is deadline-aware (M-2 until the 12th/17th, M-1 after; over-40M always M-1) mirroring deadline-config, not just calendar-ended.
[2026-08-07] Sandbox cleanup repaired via a dedicated gnubok.sandbox_cleanup transaction-local flag (respected by write_audit_log, audit_log_immutable, enforce_dimension_registry_guards, enforce_pending_operations_no_delete) instead of tombstoning sandbox users like the real account-deletion flow: sandbox data is synthetic demo content, not rakenskapsinformation, so full deletion is the correct GDPR/BFL posture and the audit-log DELETE bypass re-verifies per row that the company is a sandbox before letting anything through. Also revoked the default PUBLIC EXECUTE both cleanup RPCs had carried on prod since March (anon could call them via PostgREST), and validated the whole delete chain by replaying it against prod inside aborted transactions (21 sampled users across all seed eras) plus a committed staging run.
[2026-08-07] QuickReviewDialog's 'none'-collapse closed with a deviation test, not a blanket map: 'none' as the SEEDED default (exempt categories) stays off the wire so untouched bank-fee bookings remain byte-identical, while 'none' as a user deviation goes as explicit 'exempt' (books no VAT line, records the classification the momsdeklaration should see, lands income on 3004). The batch dialog's blanket 'none'→'exempt' map from #1443 is correct THERE because its select has no seeded-per-category default. CategoryExpandedDialog carries the same collapse but has zero references (dead code): left untouched, deletion is hygiene for a separate sweep.
[2026-08-08] Provider migration errors surface via a new PROVIDER_API_MODULE_INACTIVE code instead of reusing PROVIDER_LICENSE_MISSING: the existing code's message is Fortnox-specific (re-order the Integration license) while Visma's 4002 "No access to module: api_standard" needs different remediation (activate the API under Appar och tillägg, check standardforetag), and both differ from AUTH_EXPIRED whose "reconnect" advice loops forever since OAuth succeeds against Visma's shared identity server. Orchestrator failures split fatal/non-fatal: connection-level codes (auth expired, license missing, module inactive) rethrow and abort the run because every remaining call is doomed, while per-step upstream/unknown failures keep log-and-continue (one bad step must not discard other steps' persisted rows) but are recorded on results.stepErrors so the result UI stops presenting an all-failed sync as "Allt ar uppdaterat". Root trigger: ElvaSmultron's Visma sync failed five times server-side, reported success with zero rows, and reached us as a bug report for a customer-side subscription setting.
[2026-08-08] INK2 huvudblankett field 1.1 (Overskott av naringsverksamhet) is SRU code 7104, not 7113: verified against Skatteverket's official 2025P4 faltkoder Excel (INK2_SKV2002-33-01-24-04.xls) after prod SRU files were rejected with "UPPGIFT 7113 ar inte ett giltigt postnamn" (Miles Scherrer, Burre Nacho AB). 7113 came from our own swedish-sru-filing skill reference, which was wrong and is fixed in the same change; every other emitted INK2/INK2R/INK2S code was cross-checked against the official lists and is valid. Only profitable companies were affected (7114 for underskott was already correct).
[2026-08-08] Ruta 41 unblocked with a static ACCOUNT_RUTA mapping of 3231/3232/3233 (incl. 3232/3233, in the SKV 4700 mapping though not in our shipped BAS 2026 catalog) instead of starting the per-account VAT-treatment picker: the ElvaSmultron/Spiris support case needed omvänd-moms sales in the declaration now, and RC sales deliberately stay out of the ruta 05-08 vs 10-12 pairing checks because the buyer accounts for the VAT. The picker (any custom konto to any ruta, incl. purchase-side 4056-style Visma accounts to ruta 20) is #1457.
[2026-08-08] Community registry source of truth moved into this repo (registry/ with entries + authors, validator wired into core-build): the site button "Lagg till en egen" pointed at erp-mafia/accounted while the registry MDX lived in the private website repo, so an external contributor had no path to PR (found by the first would-be contributor). The website now syncs FROM registry/ instead of owning the content. Entry bodies are gated to plain Markdown because the site renders them through MDX, which would execute JSX and import/export statements inside the website build.
[2026-07-28] Transaction method (structured payment rail): the trailing channel phrase ("Överföring via internet", "Kortköp/uttag") is stripped from transactions.description at INGEST and by a one-shot BACKFILL, not merely hidden at render: description is the mutable working title, original_description keeps the full bank string, and every dedup surface (external_id: date+öre only; content bridge: prefix-containment over original_description ?? description, and a trailing strip leaves a prefix) is provably unaffected. transaction_method is text + CHECK (repo convention, no PG enums) beside verbatim bank_transaction_code / proprietary_bank_transaction_code evidence columns per data_quality_master Appendix B Layer-A; the dead `enrichment` jsonb was NOT reused (the Gokind lesson: opaque blobs with no readers die). mapping-engine now also matches original_description so user rules written against the full bank text keep firing on stripped rows.
+7 -2
View File
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge'
import { RowStatus, type RowStatusDescriptor } from '@/components/ui/row-status'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { FyPicker } from '@/components/common/FyPicker'
@@ -48,8 +48,13 @@ import type { FiscalPeriod, Invoice } from '@/types'
function NewInvoiceDialogLoading() {
const t = useTranslations('invoices')
// Non-modal + veil, matching NewInvoiceDialog: a modal fallback would lock
// the whole route (body pointer-events: none, no close path) if this chunk
// ever hangs or 404s on a stale deploy, and would dead-click the agent
// sheet meanwhile.
return (
<Dialog open>
<Dialog open modal={false}>
<DialogVeil />
<DialogContent className="sm:max-w-3xl">
<DialogTitle>{t('new_invoice')}</DialogTitle>
<div className="space-y-4 py-4" role="status">
@@ -118,12 +118,19 @@ interface SkipReasons {
noMatch?: number
}
interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation'
code: string | null
message: string
}
interface MigrationResults {
companyInfo?: { imported: boolean }
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; errorSample?: string }
stepErrors?: MigrationStepError[]
}
import AccountMappingStep from '@/components/import/AccountMappingStep'
import type { AccountMapping, ImportResult, ParsedSIEFile } from '@/lib/import/types'
@@ -1559,11 +1566,17 @@ function ResultStep({
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
)
const nothingNew = sieResults.length === 0 && !entityImported
// Steps that failed against the provider API. An empty sync with failed
// steps must never present as "Allt är uppdaterat": that reading sent a
// real subscription problem to the bug tracker as a sync bug.
const stepErrors = results?.stepErrors ?? []
const apiFailed = stepErrors.length > 0
const nothingNew = sieResults.length === 0 && !entityImported && !apiFailed
// Overall status
const overallIcon = anySieFailed ? 'error' as const :
(!allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const
(apiFailed || !allSieSucceeded || totalErrors > 0) ? 'warning' as const : 'success' as const
return (
<div className="space-y-4">
@@ -1573,7 +1586,7 @@ function ResultStep({
<CardTitle className="flex items-center gap-2">
<StatusIcon status={nothingNew ? 'success' : overallIcon} />
{nothingNew ? 'Allt är uppdaterat' :
anySieFailed ? 'Migrering delvis genomförd' :
(anySieFailed || apiFailed) ? 'Migrering delvis genomförd' :
!allSieSucceeded ? 'Migrering klar med anmärkningar' :
'Migrering klar'}
</CardTitle>
@@ -1598,6 +1611,23 @@ function ResultStep({
</CardHeader>
</Card>
{/* ── Steps that failed against the provider API ── */}
{stepErrors.length > 0 && (
<div className="space-y-2">
{groupStepErrors(stepErrors).map((group, i) => (
<div key={i} className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="text-sm font-medium text-destructive">
Kunde inte hämta: {group.steps.map((s) => STEP_ERROR_LABELS[s]).join(', ')}
</p>
<p className="mt-1 text-sm text-muted-foreground">{group.message}</p>
</div>
</div>
))}
</div>
)}
{/* ── Per-fiscal-year SIE breakdown ── */}
{sieResults.length > 0 && (
<div className="space-y-2">
@@ -1742,6 +1772,29 @@ function ResultStep({
)
}
const STEP_ERROR_LABELS: Record<MigrationStepError['step'], string> = {
companyInfo: 'Företagsinformation',
customers: 'Kunder',
suppliers: 'Leverantörer',
salesInvoices: 'Kundfakturor',
supplierInvoices: 'Leverantörsfakturor',
reconciliation: 'Avstämning av betalningar',
}
/**
* Group step errors that share the same message (a provider outage hits every
* step identically) so the result shows one card per cause, not one per step.
*/
function groupStepErrors(errors: MigrationStepError[]): { message: string; steps: MigrationStepError['step'][] }[] {
const groups = new Map<string, MigrationStepError['step'][]>()
for (const e of errors) {
const steps = groups.get(e.message) ?? []
steps.push(e.step)
groups.set(e.message, steps)
}
return [...groups.entries()].map(([message, steps]) => ({ message, steps }))
}
function formatSkipReasons(
reasons?: SkipReasons,
entityType?: 'customer' | 'supplier' | 'invoice',
@@ -1899,13 +1952,19 @@ export default function ArcimMigrationWorkspace({
const data = await res.json().catch(() => ({}))
// A dead connection (expired/revoked refresh token) is recoverable in
// place: flag it so the UI offers "Återanslut" instead of a dead end.
// A missing Fortnox integration license shows the same CTA but keeps the
// SIE fallback, because re-auth loops until the license is re-ordered.
// A missing Fortnox integration license or an inactive Visma API
// module shows the same CTA but keeps the SIE fallback, because
// re-auth loops until the customer fixes the subscription (re-orders
// the license / activates the API module).
const code = apiErrorCode(data)
if (code === 'PROVIDER_AUTH_EXPIRED' || code === 'PROVIDER_LICENSE_MISSING') {
if (
code === 'PROVIDER_AUTH_EXPIRED' ||
code === 'PROVIDER_LICENSE_MISSING' ||
code === 'PROVIDER_API_MODULE_INACTIVE'
) {
setAuthExpired(true)
}
if (code === 'PROVIDER_LICENSE_MISSING') {
if (code === 'PROVIDER_LICENSE_MISSING' || code === 'PROVIDER_API_MODULE_INACTIVE') {
setLicenseMissing(true)
}
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
@@ -2315,6 +2374,7 @@ export default function ArcimMigrationWorkspace({
migrationOptions.importSalesInvoices ||
migrationOptions.importSupplierInvoices
let hadStepErrors = false
if (hasApiImport) {
setMigrationStep('Importerar kunder, leverantörer och fakturor...')
setMigrationProgress(55)
@@ -2339,6 +2399,7 @@ export default function ArcimMigrationWorkspace({
const data = await res.json()
setMigrationResults(data.results)
hadStepErrors = ((data.results as MigrationResults | undefined)?.stepErrors?.length ?? 0) > 0
}
// Mark consent as fully accepted now that import is complete
@@ -2353,10 +2414,18 @@ export default function ArcimMigrationWorkspace({
setMigrationProgress(100)
setStep('result')
toast({
title: 'Migrering klar',
description: 'Din bokföringsdata har importerats.',
})
if (hadStepErrors) {
toast({
title: 'Migrering delvis genomförd',
description: 'Vissa delar kunde inte hämtas från leverantören. Se detaljerna i resultatet.',
variant: 'destructive',
})
} else {
toast({
title: 'Migrering klar',
description: 'Din bokföringsdata har importerats.',
})
}
} catch (err) {
setError(displayError(err))
setStep('result')
+19 -2
View File
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { createClient } from '@/lib/supabase/client'
@@ -119,8 +119,25 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
// The dialog is non-modal so the agent sheet (a body-level sibling at
// z-[60]) stays clickable and focusable above it: Radix modal mode sets
// body pointer-events: none and traps focus, which left the visible chat
// input dead. Page modality is restored by hand instead: `inert` on the
// dash shell blocks pointer, keyboard, and AT access to the page behind,
// while the agent sheet (outside the shell) stays live.
useEffect(() => {
if (!open) return
const shell = document.getElementById('dash-shell')
if (!shell) return
shell.inert = true
return () => {
shell.inert = false
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
<DialogVeil />
<DialogContent
className="sm:max-w-5xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed invoice must survive an accidental backdrop click or a
+17
View File
@@ -52,6 +52,22 @@ const DialogContent = React.forwardRef<
))
DialogContent.displayName = DialogPrimitive.Content.displayName
/**
* Backdrop for non-modal dialogs. Radix renders no Overlay when the root has
* `modal={false}`, so dialogs that opt out of modality (to keep the agent
* sheet interactive above them) render this alongside DialogContent to keep
* the standard veil. z-40 sits under DialogContent (z-50) and the agent
* sheet (z-60).
*/
const DialogVeil = () => (
<DialogPortal>
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 dark:bg-black/60 animate-in fade-in-0"
/>
</DialogPortal>
)
const DialogHeader = ({
className,
...props
@@ -114,6 +130,7 @@ export {
DialogClose,
DialogTrigger,
DialogContent,
DialogVeil,
DialogHeader,
DialogFooter,
DialogTitle,
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import { createMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Locks the /migrate route's mapping of a doomed provider run to a structured
* error response. When the orchestrator rethrows a connection-level failure
* (here: Visma's "No access to module: api_standard"), the route must answer
* with PROVIDER_API_MODULE_INACTIVE and its remediation, not a generic
* PROVIDER_MIGRATE_FAILED or a misleading success envelope.
*/
vi.mock('../lib/migration-orchestrator', () => ({
executeMigration: vi.fn(),
}))
vi.mock('../lib/provider-client', () => ({
createConsent: vi.fn(),
getConsent: vi.fn(),
listConsents: vi.fn(),
generateOtc: vi.fn(),
consumeOAuthState: vi.fn(),
getAuthUrl: vi.fn(),
exchangeAuthToken: vi.fn(),
submitProviderToken: vi.fn(),
acceptConsent: vi.fn().mockResolvedValue(undefined),
deleteConsent: vi.fn(),
resolveConsent: vi.fn(),
fetchCompanyInfoDirect: vi.fn(),
ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
ConsentNotFoundError: class ConsentNotFoundError extends Error {},
}))
import { arcimMigrationExtension } from '../index'
import { executeMigration } from '../lib/migration-orchestrator'
import { getConsent } from '../lib/provider-client'
const migrateRoute = (arcimMigrationExtension.apiRoutes ?? []).find(
(r) => r.method === 'POST' && r.path === '/migrate',
)!
type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise<Response>
const handler = migrateRoute.handler as RouteHandler
function buildCtx(): ExtensionContext {
const { supabase, mockResult } = createMockSupabase()
// The SIE guard awaits `from('sie_imports').select(..,{count,head}).eq().eq()`.
mockResult({ count: 1 })
;(supabase as unknown as { auth: unknown }).auth = {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
}
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
function migrateRequest() {
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
method: 'POST',
body: { consentId: 'consent-1' },
})
}
function vismaModuleError(): Error {
const e = new Error('Visma API error: 403 Forbidden') as Error & {
statusCode: number
body: string
}
e.statusCode = 403
e.body =
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
return e
}
describe('POST /migrate: provider error mapping', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('answers 403 PROVIDER_API_MODULE_INACTIVE when the orchestrator hits an inactive Visma API module', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
;(executeMigration as Mock).mockRejectedValue(vismaModuleError())
const res = await handler(migrateRequest(), buildCtx())
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
expect(status).toBe(403)
expect(body.error.code).toBe('PROVIDER_API_MODULE_INACTIVE')
expect(body.error.message).toContain('Appar och tillägg')
})
it('still returns success with stepErrors passed through for non-fatal partial failures', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
;(executeMigration as Mock).mockResolvedValue({
customers: { total: 5, imported: 5, updated: 0, skipped: 0, skipReasons: {} },
stepErrors: [
{
step: 'suppliers',
code: 'PROVIDER_UPSTREAM_ERROR',
message: 'Leverantören svarade med ett fel. Försök igen om en stund.',
},
],
})
const res = await handler(migrateRequest(), buildCtx())
const { status, body } = await parseJsonResponse<{
success: boolean
results: { stepErrors?: { step: string; code: string | null }[] }
}>(res)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.results.stepErrors).toHaveLength(1)
expect(body.results.stepErrors![0].step).toBe('suppliers')
})
})
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Locks the orchestrator's error surfacing.
*
* Before this, every step's catch was log-and-continue, so a migration where
* every provider call failed (e.g. Visma's "No access to module: api_standard"
* when the customer's plan lacks the API module) reported success with zero
* rows. The user saw "Allt är uppdaterat", retried, reconnected, and finally
* filed the config issue as a bug.
*
* Contract:
* - Connection-level failures (auth expired, license missing, API module
* inactive) doom every remaining call: the orchestrator RETHROWS so the
* route answers with the structured code and its remediation.
* - Other failures stay non-fatal (one bad step must not discard the other
* steps' persisted rows) but are recorded on results.stepErrors so the
* result UI renders them instead of implying success.
*/
vi.mock('@/lib/providers/resolve-consent', () => ({
resolveConsent: vi.fn().mockResolvedValue({
consent: { provider: 'visma' },
accessToken: 'tok',
providerCompanyId: null,
}),
}))
vi.mock('@/lib/providers/provider-data-fetcher', () => ({
fetchCompanyInfoDirect: vi.fn(),
fetchCustomersDirect: vi.fn(),
fetchSuppliersDirect: vi.fn(),
fetchSalesInvoicesDirect: vi.fn(),
fetchSupplierInvoicesDirect: vi.fn(),
}))
vi.mock('@/lib/invoices/bulk-reconcile-supplier-vouchers', () => ({
reconcileSupplierInvoiceVouchers: vi.fn(),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([]),
}))
import { executeMigration } from '../lib/migration-orchestrator'
import {
fetchCompanyInfoDirect,
fetchCustomersDirect,
} from '@/lib/providers/provider-data-fetcher'
const VISMA_MODULE_BODY =
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
function vismaError(statusCode: number, body?: string): Error {
const e = new Error(`Visma API error: ${statusCode}`) as Error & {
statusCode: number
body?: string
}
e.statusCode = statusCode
e.body = body
return e
}
function baseOptions(overrides: Record<string, unknown> = {}) {
return {
consentId: 'consent-1',
companyId: 'company-1',
userId: 'user-1',
supabase: {} as unknown as SupabaseClient,
importCompanyInfo: false,
importCustomers: false,
importSuppliers: false,
importSalesInvoices: false,
importSupplierInvoices: false,
reconcileVouchers: false,
...overrides,
}
}
describe('executeMigration: step error surfacing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rethrows when a step fails with an inactive API module (Visma 4002): the run is doomed', async () => {
;(fetchCustomersDirect as Mock).mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
await expect(
executeMigration(baseOptions({ importCustomers: true })),
).rejects.toMatchObject({ statusCode: 403 })
})
it('rethrows a doomed company-info step too, instead of settling for imported:false', async () => {
;(fetchCompanyInfoDirect as Mock).mockRejectedValue(vismaError(403, VISMA_MODULE_BODY))
await expect(
executeMigration(baseOptions({ importCompanyInfo: true })),
).rejects.toMatchObject({ statusCode: 403 })
})
it('records a non-fatal classified failure on results.stepErrors with the registry Swedish message', async () => {
;(fetchCustomersDirect as Mock).mockRejectedValue(vismaError(500))
const results = await executeMigration(baseOptions({ importCustomers: true }))
expect(results.stepErrors).toEqual([
{
step: 'customers',
code: 'PROVIDER_UPSTREAM_ERROR',
message: 'Leverantören svarade med ett fel. Försök igen om en stund.',
},
])
// The step failed before producing a result: no customers section.
expect(results.customers).toBeUndefined()
})
it('records an unclassified failure with a generic Swedish sentence carrying the raw reason', async () => {
;(fetchCustomersDirect as Mock).mockRejectedValue(new Error('boom'))
const results = await executeMigration(baseOptions({ importCustomers: true }))
expect(results.stepErrors).toEqual([
{ step: 'customers', code: null, message: 'Leverantören svarade med ett fel: boom' },
])
})
it('returns no stepErrors when every enabled step succeeds', async () => {
;(fetchCustomersDirect as Mock).mockResolvedValue([])
const results = await executeMigration(baseOptions({ importCustomers: true }))
expect(results.stepErrors).toBeUndefined()
expect(results.customers).toEqual({
total: 0,
imported: 0,
updated: 0,
skipped: 0,
skipReasons: {},
})
})
})
@@ -640,6 +640,16 @@ export const arcimMigrationExtension: Extension = {
const companyInfo = await fetchCompanyInfoDirect(provider, resolved.accessToken, resolved.providerCompanyId)
mapped = companyInfo ? mapCompanyInfo(companyInfo) : null
} catch (err) {
// A missing integration license / inactive API module dooms every
// later call in the wizard too: fail the preview with the typed
// code (via the outer catch) so the user reads the remediation at
// connect time, not after a silently empty migration. Other
// failures stay soft: the preview is still useful without company
// info (e.g. SIE-over-API can work with narrower scopes).
const classified = classifyProviderError(err)
if (classified === 'PROVIDER_API_MODULE_INACTIVE' || classified === 'PROVIDER_LICENSE_MISSING') {
throw err
}
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
}
@@ -18,8 +18,10 @@
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MigrationProgress, MigrationResults, SkipReasons } from '../types'
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
import type { ProviderName } from '@/lib/providers/types'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto'
import { resolveConsent } from '@/lib/providers/resolve-consent'
import { normalizeVatNumber, isValidSwedishVatNumber } from '@/lib/vat/vat-number'
@@ -84,6 +86,42 @@ function chunk<T>(arr: T[], size: number): T[][] {
return out
}
/**
* Codes that doom every remaining provider call in this run: dead/expired
* grant, missing integration license, API module not activated. Continuing
* would fail four more times with the same cause and then report a
* "successful" migration with zero rows (the failure mode that sent a real
* config issue to the bug tracker). Rethrow so /migrate answers with the
* structured code and the wizard shows the actual remediation.
*/
const FATAL_STEP_ERROR_CODES = new Set([
'PROVIDER_AUTH_EXPIRED',
'PROVIDER_LICENSE_MISSING',
'PROVIDER_API_MODULE_INACTIVE',
])
/**
* Record a failed step on the results so the UI can render it. Non-fatal
* failures keep the log-and-continue behavior (one bad step must not discard
* the others' already-persisted rows); fatal connection-level failures
* rethrow, see FATAL_STEP_ERROR_CODES.
*/
function recordStepError(
results: MigrationResults,
step: MigrationStepError['step'],
err: unknown,
): void {
const code = classifyProviderError(err)
if (code && FATAL_STEP_ERROR_CODES.has(code)) throw err
const rawMessage = err instanceof Error ? err.message : String(err)
const entry = code ? getErrorEntry(code) : undefined
const message = entry?.message_sv ?? `Leverantören svarade med ett fel: ${rawMessage}`
results.stepErrors = results.stepErrors ?? []
results.stepErrors.push({ step, code, message })
}
function getOrgNumberFromParty(party: PartyDto): string | null {
return (
party.legalEntity?.companyId ||
@@ -178,6 +216,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
} catch (err) {
console.error('Failed to import company info:', err)
results.companyInfo = { imported: false }
recordStepError(results, 'companyInfo', err)
}
}
@@ -341,6 +380,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.customers = { total: customers.length, imported, updated, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import customers:', err)
recordStepError(results, 'customers', err)
}
}
@@ -440,6 +480,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.suppliers = { total: suppliers.length, imported, skipped, skipReasons, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import suppliers:', err)
recordStepError(results, 'suppliers', err)
}
}
@@ -652,6 +693,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.salesInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import sales invoices:', err)
recordStepError(results, 'salesInvoices', err)
}
}
@@ -878,6 +920,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
results.supplierInvoices = { total: invoices.length, imported, skipped, skipReasons, fxUnresolved, errorSample: errorSample ?? undefined }
} catch (err) {
console.error('Failed to import supplier invoices:', err)
recordStepError(results, 'supplierInvoices', err)
}
}
@@ -902,6 +945,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
)
} catch (err) {
console.error('Failed to reconcile supplier invoice payments:', err)
recordStepError(results, 'reconciliation', err)
}
}
@@ -67,6 +67,19 @@ export interface SkipReasons {
noMatch?: number
}
/**
* A migration step that failed against the provider API, surfaced to the user
* instead of being swallowed into a "successful" empty sync. `message` is the
* user-facing Swedish text (mapped from the structured error registry when the
* failure classifies, otherwise a generic sentence with the provider's reply).
*/
export interface MigrationStepError {
step: 'companyInfo' | 'customers' | 'suppliers' | 'salesInvoices' | 'supplierInvoices' | 'reconciliation'
/** Structured code when the failure classifies (e.g. PROVIDER_API_MODULE_INACTIVE), else null. */
code: string | null
message: string
}
/**
* Foreign-currency invoices that were imported but whose SEK value could not
* be established (currency outside Riksbanken's series, or no observation for
@@ -89,6 +102,12 @@ export interface MigrationResults {
* candidate voucher.
*/
reconciliation?: { scanned: number; autoLinked: number; ambiguous: number; unmatched: number }
/**
* Steps that failed against the provider API. Present (non-empty) whenever a
* step's fetch or import threw: the result step must render these instead of
* implying the sync succeeded with zero rows.
*/
stepErrors?: MigrationStepError[]
}
// ── Consent flow ────────────────────────────────────────────────────
+7
View File
@@ -2495,6 +2495,13 @@ const PROVIDER: Record<string, StructuredErrorEntry> = {
message_en:
'Fortnox refused the connection because the integration license is not active. Activate the "Fortnox Integration" add-on in your Fortnox account, then reconnect. You can also import via SIE file in the meantime.',
},
PROVIDER_API_MODULE_INACTIVE: {
httpStatus: 403,
message_sv:
'Visma nekade åtkomst eftersom API-modulen inte är aktiverad för företaget ("No access to module: api_standard"). Aktivera API:et i Visma/Spiris under Inställningar, Appar och tillägg. På de mindre abonnemangen är API:et ett tillägg (Integration) som kostar extra. Kontrollera också att inget standardföretag är valt i menyn uppe till höger i Visma, det kan göra att inloggningen hamnar på ett företag utan giltig licens. Försök sedan igen. Du kan även importera via SIE-fil under tiden.',
message_en:
'Visma refused access because the API module is not activated for the company ("No access to module: api_standard"). Activate the API in Visma/Spiris under Settings, Apps and extensions (on smaller plans the API is a paid add-on called Integration), and make sure no default company is selected in the top-right menu. Then try again. You can also import via SIE file in the meantime.',
},
PROVIDER_RATE_LIMITED: {
httpStatus: 429,
message_sv:
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import {
classifyProviderError,
isApiModuleInactiveError,
ProviderCallError,
} from '../with-provider-call'
import { getErrorEntry } from '@/lib/errors/structured-errors'
/**
* Locks the classification of provider-client failures into structured codes.
*
* The load-bearing case is Visma's 403 `ForbiddenRequestException - No access
* to module: api_standard` (ErrorCode 4002): the customer's plan lacks the API
* module, OAuth still succeeds, and re-authorizing loops forever. Before this
* classification it mapped to PROVIDER_AUTH_EXPIRED ("återanslut"), which sent
* a real user into exactly that loop and their report into the bug tracker.
*/
const VISMA_MODULE_BODY =
'{"ErrorCode":4002,"DeveloperErrorMessage":"ForbiddenRequestException - No access to module: api_standard","ErrorId":"x","Errors":[]}'
/** Mirror of VismaApiError's shape: statusCode + body on a plain Error. */
function vismaError(statusCode: number, body?: string): Error {
const e = new Error(`Visma API error: ${statusCode}`) as Error & {
statusCode: number
body?: string
}
e.statusCode = statusCode
e.body = body
return e
}
describe('classifyProviderError', () => {
it('maps a Visma 403 with a "No access to module" body to PROVIDER_API_MODULE_INACTIVE, not AUTH_EXPIRED', () => {
expect(classifyProviderError(vismaError(403, VISMA_MODULE_BODY))).toBe(
'PROVIDER_API_MODULE_INACTIVE',
)
})
it('keeps a bare 403 (no module body) as PROVIDER_AUTH_EXPIRED', () => {
expect(classifyProviderError(vismaError(403))).toBe('PROVIDER_AUTH_EXPIRED')
})
it('maps a Fortnox missing-license message to PROVIDER_LICENSE_MISSING', () => {
expect(classifyProviderError(new Error('token refresh failed: error_missing_license'))).toBe(
'PROVIDER_LICENSE_MISSING',
)
})
it('maps 429 and 5xx as before', () => {
expect(classifyProviderError(vismaError(429))).toBe('PROVIDER_RATE_LIMITED')
expect(classifyProviderError(vismaError(500))).toBe('PROVIDER_UPSTREAM_ERROR')
})
it('passes ProviderCallError codes through unchanged', () => {
const err = new ProviderCallError('PROVIDER_API_MODULE_INACTIVE', 'visma', 'module inactive')
expect(classifyProviderError(err)).toBe('PROVIDER_API_MODULE_INACTIVE')
})
it('returns null for an unclassifiable error', () => {
expect(classifyProviderError(new Error('boom'))).toBeNull()
expect(classifyProviderError('not an error')).toBeNull()
})
})
describe('isApiModuleInactiveError', () => {
it('matches the Visma module string case-insensitively', () => {
expect(isApiModuleInactiveError(VISMA_MODULE_BODY)).toBe(true)
expect(isApiModuleInactiveError('NO ACCESS TO MODULE: api_standard')).toBe(true)
})
it('does not match unrelated 403 bodies', () => {
expect(isApiModuleInactiveError('Forbidden: invalid token')).toBe(false)
})
})
describe('structured error registry wiring', () => {
it('PROVIDER_API_MODULE_INACTIVE has a 403 entry with Swedish remediation', () => {
const entry = getErrorEntry('PROVIDER_API_MODULE_INACTIVE')
expect(entry).toBeDefined()
expect(entry!.httpStatus).toBe(403)
expect(entry!.message_sv).toContain('Appar och tillägg')
expect(entry!.message_en).toBeTruthy()
})
})
+32
View File
@@ -14,6 +14,7 @@ import { createLogger, type Logger } from '@/lib/logger'
export type ProviderCallErrorCode =
| 'PROVIDER_AUTH_EXPIRED'
| 'PROVIDER_LICENSE_MISSING'
| 'PROVIDER_API_MODULE_INACTIVE'
| 'PROVIDER_RATE_LIMITED'
| 'PROVIDER_UNREACHABLE'
| 'PROVIDER_UPSTREAM_ERROR'
@@ -198,6 +199,18 @@ export function classifyProviderError(error: unknown): ProviderCallErrorCode | n
(error as Error & { statusCode?: number; status?: number }).statusCode ??
(error as Error & { statusCode?: number; status?: number }).status
// Provider clients (e.g. VismaApiError) carry the response body separately
// from the Error message; both can hold the discriminating string.
const body = (error as Error & { body?: unknown }).body
const haystack = typeof body === 'string' ? `${error.message} ${body}` : error.message
// Order matters: 401/403 with a module/license body is a subscription
// problem, not a dead token. Mapping it to AUTH_EXPIRED would send the user
// into a reconnect loop that can never succeed (the exact failure mode this
// classification exists to prevent).
if (isApiModuleInactiveError(haystack)) return 'PROVIDER_API_MODULE_INACTIVE'
if (isMissingLicenseError(haystack)) return 'PROVIDER_LICENSE_MISSING'
if (typeof status === 'number') {
if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED'
if (status === 429) return 'PROVIDER_RATE_LIMITED'
@@ -232,3 +245,22 @@ export function isMissingLicenseError(message: string): boolean {
haystack.includes('not have enough licenses')
)
}
/**
* True when a provider 403 means the customer's subscription has API access
* switched off or not included, NOT an expired/revoked grant.
*
* Visma eAccounting (Spiris) answers every data endpoint with
* `ForbiddenRequestException - No access to module: api_standard`
* (ErrorCode 4002) when the company's plan lacks the API module or it is not
* activated under "Appar och tillägg". OAuth still succeeds (the identity
* server is shared), so the stored tokens are valid; re-authorizing loops
* forever. The fix is on the customer's side: activate the API module (an
* add-on on smaller plans) and clear any "standardföretag" selection.
*
* Matches the raw provider body/message string, same approach as
* isMissingLicenseError above.
*/
export function isApiModuleInactiveError(message: string): boolean {
return /no access to module/i.test(message)
}
@@ -236,7 +236,7 @@ describe('generateINK2Declaration: closed fiscal year', () => {
// 442 000 + 60 000 + 4 000
expect(result.ink2s['8020']).toBe(506_000)
expect(result.ink2s['8021']).toBe(0)
expect(result.ink2['7113']).toBe(506_000)
expect(result.ink2['7104']).toBe(506_000)
})
it('does not re-add the periodiseringsfond, which is already in the result', async () => {
@@ -39,7 +39,7 @@ function makeDeclaration(overrides?: Partial<INK2Declaration>): INK2Declaration
ink2: {
'7011': '20250101',
'7012': '20251231',
'7113': 302000,
'7104': 302000,
'7114': 0,
},
ink2r: defaultInk2r,
@@ -271,7 +271,7 @@ describe('INK2 SRU Generator', () => {
const submission = generateSRUSubmission(declaration)
const ink2Block = extractBlock(submission.blanketterSru, 'INK2-')
expect(ink2Block).toContain('#UPPGIFT 7113 302000')
expect(ink2Block).toContain('#UPPGIFT 7104 302000')
// 7114 (underskott) is 0, should not appear
expect(ink2Block).not.toContain('#UPPGIFT 7114')
})
+1 -1
View File
@@ -1024,7 +1024,7 @@ export async function generateINK2Declaration(
const ink2: INK2Rutor = {
'7011': fyStart,
'7012': fyEnd,
'7113': taxableResult >= 0 ? taxableResult : 0,
'7104': taxableResult >= 0 ? taxableResult : 0,
'7114': taxableResult < 0 ? Math.abs(taxableResult) : 0,
}
+2 -2
View File
@@ -157,8 +157,8 @@ function generateBlanketterSru(declaration: INK2Declaration, now: Date): string
lines.push(`#UPPGIFT 7012 ${declaration.ink2['7012']}`)
// Överskott/underskott
if (declaration.ink2['7113'] > 0) {
lines.push(`#UPPGIFT 7113 ${formatAmount(declaration.ink2['7113'])}`)
if (declaration.ink2['7104'] > 0) {
lines.push(`#UPPGIFT 7104 ${formatAmount(declaration.ink2['7104'])}`)
}
if (declaration.ink2['7114'] > 0) {
lines.push(`#UPPGIFT 7114 ${formatAmount(declaration.ink2['7114'])}`)
+1 -1
View File
@@ -95,7 +95,7 @@ export type INK2RSRUCode = keyof INK2RRutor
export interface INK2Rutor {
'7011': string // Räkenskapsår fr.o.m. (YYYYMMDD)
'7012': string // Räkenskapsår t.o.m. (YYYYMMDD)
'7113': number // 1.1 Överskott av näringsverksamhet
'7104': number // 1.1 Överskott av näringsverksamhet
'7114': number // 1.2 Underskott av näringsverksamhet
}
+4 -4
View File
@@ -192,12 +192,12 @@
"version": 1
},
"horizontal/swedish-sru-filing": {
"hash": "5a8f288a668b8c67f6f77986d45ceb1921fe483c8d1c00a28039a8fb89f584c4",
"version": 5
"hash": "56a1ce1be4908e15b6e3c9dcad64e5a3fb0f4400c47a2f88504d0bf897f4a9da",
"version": 6
},
"horizontal/swedish-sru-filing/sru-codes": {
"hash": "90b588d78894bc7c546c5bff42c1c43a339c21db4efd51f8b4a0930e2609db83",
"version": 2
"hash": "39625f1122caf0d3d034a98f2e5daabc2d60d2a44fd299010c9d39f58cf6635f",
"version": 3
},
"horizontal/swedish-tax-planning": {
"hash": "99b87fac9eacf0ad168434c2bce99f21cdb8a53cb26ccabb64ba291b9059aa24",
File diff suppressed because it is too large Load Diff