fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a43a8b03cf
commit
f24b26a139
@@ -7,6 +7,7 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
|
||||
import { entityMagnitude, selectAccountRing, type RingBasis } from './ledger-graph-magnitude'
|
||||
|
||||
/**
|
||||
* "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page.
|
||||
@@ -18,7 +19,7 @@ import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-d
|
||||
* never cross between accounts (the anti-hairball guarantee).
|
||||
*
|
||||
* Four orthogonal channels, so no two compete:
|
||||
* - node AREA = total spend (radius = k·√kr)
|
||||
* - node AREA = magnitude (radius = k·√x), see ledger-graph-magnitude.ts
|
||||
* - node COLOUR = booking cadence (weekly / monthly / irregular) - the one
|
||||
* semantic axis; everything else stays achromatic
|
||||
* - node SHAPE = kind (supplier = filled, counterparty = open ring)
|
||||
@@ -45,8 +46,6 @@ const R_HUB = 34
|
||||
const R_ACCOUNT = 178
|
||||
const R_PAYEE_MIN = 258
|
||||
const R_PAYEE_MAX = 432
|
||||
const MAX_ACCOUNTS = 9
|
||||
const MAX_PER_ACCOUNT = 6
|
||||
const WEDGE_GAP = 0.07 // radians of padding between account wedges
|
||||
|
||||
// This panel is its own dark world regardless of the app theme, so the depth of
|
||||
@@ -95,6 +94,8 @@ interface Model {
|
||||
accounts: Account[]
|
||||
payees: Payee[]
|
||||
truncated: boolean
|
||||
/** What the wedge widths and node areas actually measure. See the legend. */
|
||||
basis: RingBasis
|
||||
totals: { tx: number; payees: number; accounts: number }
|
||||
}
|
||||
|
||||
@@ -154,41 +155,14 @@ function buildModel(deep: DeepLedgerContext): Model {
|
||||
|
||||
const totalTx = all.reduce((s, e) => s + e.occurrences, 0)
|
||||
|
||||
const byAccount = new Map<string, DeepEntity[]>()
|
||||
for (const e of all) {
|
||||
const acc = e.dominant_account_number as string
|
||||
const arr = byAccount.get(acc) ?? []
|
||||
arr.push(e)
|
||||
byAccount.set(acc, arr)
|
||||
}
|
||||
|
||||
// Weight a wedge by the money that flows through it (fall back to volume).
|
||||
const activity = (items: DeepEntity[]) => {
|
||||
const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0)
|
||||
return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0)
|
||||
}
|
||||
|
||||
let groups = [...byAccount.entries()].map(([number, items]) => ({
|
||||
number,
|
||||
items: items.slice().sort((a, b) => b.total_amount - a.total_amount),
|
||||
weight: activity(items),
|
||||
}))
|
||||
// Rank by weight to pick what to show, then lay out in a fixed order (account
|
||||
// number) so wedges never swap places between loads.
|
||||
groups.sort((a, b) => b.weight - a.weight)
|
||||
const totalAccounts = groups.length
|
||||
groups = groups.slice(0, MAX_ACCOUNTS)
|
||||
let truncated = totalAccounts > groups.length
|
||||
for (const g of groups) {
|
||||
if (g.items.length > MAX_PER_ACCOUNT) {
|
||||
truncated = true
|
||||
g.items = g.items.slice(0, MAX_PER_ACCOUNT)
|
||||
}
|
||||
}
|
||||
groups.sort((a, b) => a.number.localeCompare(b.number))
|
||||
|
||||
const shownWeight = groups.reduce((s, g) => s + g.weight, 0) || 1
|
||||
const maxSpend = Math.max(...groups.flatMap((g) => g.items.map((i) => i.total_amount)), 1)
|
||||
// Grouping, weighting, ranking and truncation of the account ring live in
|
||||
// ledger-graph-magnitude.ts so they can be unit tested. The one rule that
|
||||
// matters here: every wedge weight below is expressed in the SAME unit
|
||||
// (`ring.basis`), so the shared denominator never adds kronor to booking
|
||||
// counts and no account is squeezed to an invisible sliver, or truncated
|
||||
// away entirely, just for being measured differently.
|
||||
const ring = selectAccountRing(all)
|
||||
const { basis, groups, truncated } = ring
|
||||
|
||||
// The most name-merged payees earn the on-mount "descriptors collapse" moment.
|
||||
const mergeIds = new Set(
|
||||
@@ -205,7 +179,7 @@ function buildModel(deep: DeepLedgerContext): Model {
|
||||
const spans = 2 * Math.PI - WEDGE_GAP * groups.length
|
||||
let angle = -Math.PI / 2 + WEDGE_GAP / 2
|
||||
groups.forEach((g, gi) => {
|
||||
const width = spans * (g.weight / shownWeight)
|
||||
const width = spans * (g.weight / ring.totalWeight)
|
||||
const mid = angle + width / 2
|
||||
const anchor = polar(CX, CY, R_ACCOUNT, mid)
|
||||
|
||||
@@ -226,8 +200,10 @@ function buildModel(deep: DeepLedgerContext): Model {
|
||||
const slot = order[rank]
|
||||
const t = n === 1 ? 0.5 : slot / (n - 1)
|
||||
const pa = angle + inner + t * (width - 2 * inner)
|
||||
const spendFrac = Math.sqrt(Math.max(e.total_amount, 0) / maxSpend)
|
||||
const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + spendFrac * 14
|
||||
// Same unit as the wedge it sits in, so a node is never sized against a
|
||||
// maximum measured in something else.
|
||||
const sizeFrac = Math.sqrt(entityMagnitude(e, basis) / ring.maxMagnitude)
|
||||
const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + sizeFrac * 14
|
||||
const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa)
|
||||
const id = `${g.number}:${e.key}`
|
||||
payees.push({
|
||||
@@ -236,7 +212,7 @@ function buildModel(deep: DeepLedgerContext): Model {
|
||||
accountNumber: g.number,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
r: 7 + 19 * spendFrac,
|
||||
r: 7 + 19 * sizeFrac,
|
||||
cadence: cadenceOf(e),
|
||||
bucket: bucketOf(e.dominant_account_share),
|
||||
thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`,
|
||||
@@ -254,6 +230,7 @@ function buildModel(deep: DeepLedgerContext): Model {
|
||||
accounts,
|
||||
payees,
|
||||
truncated,
|
||||
basis,
|
||||
totals: { tx: totalTx, payees: payees.length, accounts: accounts.length },
|
||||
}
|
||||
}
|
||||
@@ -509,7 +486,12 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Dot fill={CAD.irregular} /> {t('cadence_irregular')}
|
||||
</span>
|
||||
<span className="opacity-70">{t('legend_size')}</span>
|
||||
{/* Says what the sizes actually measure. The ring drops to booking
|
||||
volume whenever any account lacks a usable amount, and claiming
|
||||
"Storlek = belopp" there would describe a unit nothing is drawn in. */}
|
||||
<span className="opacity-70">
|
||||
{model.basis === 'amount' ? t('legend_size') : t('legend_size_volume')}
|
||||
</span>
|
||||
<span className="opacity-70">{t('legend_focus')}</span>
|
||||
{model.truncated && <span className="italic opacity-70">{t('graph_truncated')}</span>}
|
||||
</div>
|
||||
@@ -755,9 +737,18 @@ function DetailCard({ p, t }: { p: Payee; t: ReturnType<typeof useTranslations>
|
||||
<span style={{ color: PAPER }}>×{e.variant_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Its own label: `legend_size` now describes whichever unit the ring
|
||||
settled on, which is not always the amount. Rendered as a plain
|
||||
number with NO currency suffix: total_amount is the RAW FOREIGN
|
||||
amount when no SEK equivalent exists (ledger-graph-magnitude.ts),
|
||||
so labelling it "kr" would show a 500 EUR supplier as "500 kr".
|
||||
Proper per-currency display is deferred to the RPC fix documented
|
||||
in that module. */}
|
||||
<div className="flex justify-between gap-3">
|
||||
<span>{t('legend_size')}</span>
|
||||
<span className="tabular-nums" style={{ color: PAPER }}>{formatCurrency(e.total_amount)}</span>
|
||||
<span>{t('card_amount')}</span>
|
||||
<span className="tabular-nums" style={{ color: PAPER }}>
|
||||
{e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-2" style={{ borderColor: HAIR }}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
entityMagnitude,
|
||||
selectAccountRing,
|
||||
MAX_ACCOUNTS,
|
||||
} from '@/components/agent-knowledge/ledger-graph-magnitude'
|
||||
import type { DeepEntity } from '@/lib/agent-context/ledger-deep'
|
||||
|
||||
function entity(overrides: Partial<DeepEntity> & { key: string }): DeepEntity {
|
||||
return {
|
||||
name: overrides.key,
|
||||
variants: [overrides.key],
|
||||
variant_count: 1,
|
||||
occurrences: 1,
|
||||
total_amount: 0,
|
||||
first_seen: '2026-01-01',
|
||||
last_seen: '2026-06-01',
|
||||
cadence_days: null,
|
||||
dominant_account_number: '5420',
|
||||
dominant_account_share: 0.8,
|
||||
dominant_account_count: 4,
|
||||
dominant_account_total: 5,
|
||||
kind: 'counterparty',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Share of the ring an account gets, ignoring the fixed inter-wedge padding. */
|
||||
function share(weight: number, totalWeight: number): number {
|
||||
return weight / totalWeight
|
||||
}
|
||||
|
||||
describe('selectAccountRing', () => {
|
||||
it('weighs a SEK-only company by amount, exactly as before', () => {
|
||||
const ring = selectAccountRing([
|
||||
entity({ key: 'a', dominant_account_number: '5420', total_amount: 24000, occurrences: 12 }),
|
||||
entity({ key: 'b', dominant_account_number: '5420', total_amount: 6000, occurrences: 3 }),
|
||||
entity({ key: 'c', dominant_account_number: '6540', total_amount: 10000, occurrences: 2 }),
|
||||
])
|
||||
|
||||
expect(ring.basis).toBe('amount')
|
||||
expect(ring.groups.map((g) => g.number)).toEqual(['5420', '6540'])
|
||||
expect(ring.groups.map((g) => g.weight)).toEqual([30000, 10000])
|
||||
expect(ring.totalWeight).toBe(40000)
|
||||
expect(ring.maxMagnitude).toBe(24000)
|
||||
expect(ring.truncated).toBe(false)
|
||||
// Biggest spender first inside the wedge: it lands at the angular centre.
|
||||
expect(ring.groups[0].items.map((e) => e.key)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('never puts kronor and booking counts in the same denominator', () => {
|
||||
// 6570 is booked 4 times but carries no usable amount: on the counterparty
|
||||
// side that is a spend of 0, and it used to fall back to its booking count
|
||||
// while 5420 stayed in kronor.
|
||||
const entities = [
|
||||
entity({ key: 'big', dominant_account_number: '5420', total_amount: 250000, occurrences: 6 }),
|
||||
entity({ key: 'nil', dominant_account_number: '6570', total_amount: 0, occurrences: 4 }),
|
||||
]
|
||||
|
||||
const ring = selectAccountRing(entities)
|
||||
|
||||
expect(ring.basis).toBe('volume')
|
||||
expect(ring.groups.map((g) => [g.number, g.weight])).toEqual([
|
||||
['5420', 6],
|
||||
['6570', 4],
|
||||
])
|
||||
expect(ring.totalWeight).toBe(10)
|
||||
// No weight is a kronor figure, so nothing can be summed across units.
|
||||
expect(ring.groups.every((g) => g.weight < 1000)).toBe(true)
|
||||
|
||||
// The pre-fix mix: 250 000 kr against 4 bookings in one denominator gave
|
||||
// 6570 a wedge of 0.0016% of the circle, an invisible sliver. Now it is a
|
||||
// legible tenth of the ring.
|
||||
const mixedShare = share(4, 250000 + 4)
|
||||
expect(mixedShare).toBeLessThan(0.0001)
|
||||
expect(share(ring.groups[1].weight, ring.totalWeight)).toBeCloseTo(0.4, 10)
|
||||
})
|
||||
|
||||
it('keeps an account with no usable amount inside the truncation cap', () => {
|
||||
// Ten accounts, cap is nine. The amount-less one is the most-booked account
|
||||
// the company has, so dropping it is the worst possible choice.
|
||||
const entities: DeepEntity[] = [
|
||||
entity({ key: 'unpriced', dominant_account_number: '4000', total_amount: 0, occurrences: 40 }),
|
||||
]
|
||||
for (let i = 0; i < 9; i++) {
|
||||
entities.push(
|
||||
entity({
|
||||
key: `priced-${i}`,
|
||||
dominant_account_number: `55${10 + i}`,
|
||||
total_amount: 100000 + i,
|
||||
occurrences: 2,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ring = selectAccountRing(entities)
|
||||
|
||||
expect(ring.groups).toHaveLength(MAX_ACCOUNTS)
|
||||
expect(ring.truncated).toBe(true)
|
||||
expect(ring.groups.map((g) => g.number)).toContain('4000')
|
||||
// It is the largest wedge, not a survivor by luck.
|
||||
const unpriced = ring.groups.find((g) => g.number === '4000')
|
||||
expect(unpriced?.weight).toBe(40)
|
||||
expect(Math.max(...ring.groups.map((g) => g.weight))).toBe(40)
|
||||
})
|
||||
|
||||
it('sizes nodes against a maximum measured in the same unit', () => {
|
||||
const ring = selectAccountRing([
|
||||
entity({ key: 'a', dominant_account_number: '5420', total_amount: 0, occurrences: 9 }),
|
||||
entity({ key: 'b', dominant_account_number: '6540', total_amount: 500, occurrences: 3 }),
|
||||
])
|
||||
|
||||
expect(ring.basis).toBe('volume')
|
||||
expect(ring.maxMagnitude).toBe(9)
|
||||
// The 500 is not compared against 9: under a volume ring it is not read.
|
||||
expect(entityMagnitude(ring.groups[1].items[0], ring.basis)).toBe(3)
|
||||
})
|
||||
|
||||
it('truncates payees per wedge without changing what the wedge weighs', () => {
|
||||
const items = Array.from({ length: 8 }, (_, i) =>
|
||||
entity({ key: `e${i}`, dominant_account_number: '5420', total_amount: 1000, occurrences: 1 }),
|
||||
)
|
||||
|
||||
const ring = selectAccountRing(items, { maxPerAccount: 6 })
|
||||
|
||||
expect(ring.truncated).toBe(true)
|
||||
expect(ring.groups[0].items).toHaveLength(6)
|
||||
expect(ring.groups[0].weight).toBe(8000)
|
||||
})
|
||||
|
||||
it('drops entities with no dominant account and survives an empty ledger', () => {
|
||||
const ring = selectAccountRing([
|
||||
entity({ key: 'orphan', dominant_account_number: null, total_amount: 900, occurrences: 2 }),
|
||||
])
|
||||
|
||||
expect(ring.groups).toEqual([])
|
||||
expect(ring.basis).toBe('volume')
|
||||
// Safe divisors: the layout divides by both.
|
||||
expect(ring.totalWeight).toBe(1)
|
||||
expect(ring.maxMagnitude).toBe(1)
|
||||
})
|
||||
|
||||
it('is deterministic when weights tie', () => {
|
||||
const build = (order: string[]) =>
|
||||
selectAccountRing(
|
||||
order.map((number) =>
|
||||
entity({ key: number, dominant_account_number: number, total_amount: 5000, occurrences: 1 }),
|
||||
),
|
||||
{ maxAccounts: 2 },
|
||||
)
|
||||
|
||||
expect(build(['6000', '5000', '7000']).groups.map((g) => g.number)).toEqual(['5000', '6000'])
|
||||
expect(build(['7000', '6000', '5000']).groups.map((g) => g.number)).toEqual(['5000', '6000'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('entityMagnitude', () => {
|
||||
it('clamps a negative amount rather than subtracting it from a sibling', () => {
|
||||
const e = entity({ key: 'credit', total_amount: -4000, occurrences: 2 })
|
||||
expect(entityMagnitude(e, 'amount')).toBe(0)
|
||||
expect(entityMagnitude(e, 'volume')).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Pure account-ring selection and weighting for `LedgerGraph`.
|
||||
*
|
||||
* Lives outside the component (which is `'use client'` and pure JSX) so the
|
||||
* rule below can be unit tested: this repo runs Vitest in the `node`
|
||||
* environment and never renders components, so logic embedded in a component
|
||||
* is unverifiable by construction. Same arrangement as
|
||||
* `components/transactions/invoice-candidate-ranking.ts`.
|
||||
*
|
||||
* # Why this exists
|
||||
*
|
||||
* The ring weighted each account wedge with:
|
||||
*
|
||||
* const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0)
|
||||
* return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0)
|
||||
*
|
||||
* The fallback is per group, but the weights are then summed into one
|
||||
* denominator (`shownWeight`) and ranked against each other. So an account
|
||||
* whose entities carry no usable amount contributed a booking COUNT (say 4)
|
||||
* into a denominator otherwise made of kronor (say 250 000), and:
|
||||
*
|
||||
* - its wedge came out ~0.002% of the circle, i.e. an invisible sliver whose
|
||||
* payee nodes all collapse onto one angle, and
|
||||
* - it sorted below every money-weighted account, so it was the first thing
|
||||
* dropped by the top-`maxAccounts` truncation.
|
||||
*
|
||||
* An account the agent genuinely books to therefore vanished from the map for
|
||||
* no reason other than its magnitude being expressed in a different unit.
|
||||
* Silent exclusion, which is exactly what this surface must not do.
|
||||
*
|
||||
* The rule here: the whole ring is measured in ONE unit. Amount if every
|
||||
* account has a usable amount, booking volume otherwise. Never a mix. The unit
|
||||
* is decided over every account, not just the nine that end up shown, because
|
||||
* the unit is what ranks them: deciding it from the survivors would be circular
|
||||
* and would reintroduce the truncation bug it exists to prevent. The chosen
|
||||
* unit is returned as `basis` so the legend can say which one the sizes
|
||||
* actually mean instead of always claiming "Storlek = belopp".
|
||||
*
|
||||
* # What this does NOT fix
|
||||
*
|
||||
* `DeepEntity.total_amount` arrives from `get_ledger_deep_context` already
|
||||
* summed to a single scalar per entity, built as
|
||||
* `abs(coalesce(t.amount_sek, t.amount))` on the counterparty side and
|
||||
* `coalesce(si.total_sek, si.total, 0)` on the supplier side. That is SEK when
|
||||
* a SEK equivalent was recorded and the RAW FOREIGN AMOUNT otherwise, and the
|
||||
* RPC projects neither `currency` nor a per-row breakdown. A 500 EUR supplier
|
||||
* invoice with no stored exchange rate therefore reaches this file as the
|
||||
* number 500, indistinguishable from 500 kr.
|
||||
*
|
||||
* Nothing here can undo that: by the time the payload exists the currencies
|
||||
* have already been added together upstream, and no field survives that would
|
||||
* let us split them, convert them or even count them. Forming a per-currency
|
||||
* total on this side would mean inventing one. The fix belongs in the RPC
|
||||
* (project `total_amount_sek` + per-currency totals + a count of rows with no
|
||||
* SEK equivalent, and drop the raw-foreign fallback); this file is written so
|
||||
* that the moment those fields exist, `entityMagnitude` is the single place
|
||||
* that has to learn about them.
|
||||
*/
|
||||
import type { DeepEntity } from '@/lib/agent-context/ledger-deep'
|
||||
|
||||
/** How many account wedges the ring shows before it truncates. */
|
||||
export const MAX_ACCOUNTS = 9
|
||||
/** How many payee nodes one wedge shows before it truncates. */
|
||||
export const MAX_PER_ACCOUNT = 6
|
||||
|
||||
/**
|
||||
* The unit the whole ring is measured in.
|
||||
*
|
||||
* - `amount`: `total_amount` as delivered by the RPC (kronor, with the caveat
|
||||
* in the file header). Only chosen when every account has one.
|
||||
* - `volume`: number of bookings. Currency-free, so it is always available and
|
||||
* is what the ring falls back to rather than mixing units.
|
||||
*/
|
||||
export type RingBasis = 'amount' | 'volume'
|
||||
|
||||
export interface RingGroup {
|
||||
/** BAS account number. Strings, never numbers (CLAUDE.md). */
|
||||
number: string
|
||||
/** Entities booked to this account, biggest magnitude first. */
|
||||
items: DeepEntity[]
|
||||
/**
|
||||
* Wedge weight, in `basis` units. Computed over ALL the account's entities,
|
||||
* including the ones `items` truncated away, so the wedge keeps the size the
|
||||
* account actually earned.
|
||||
*/
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface RingSelection {
|
||||
basis: RingBasis
|
||||
/** Shown accounts, ordered by account number so wedges never swap places. */
|
||||
groups: RingGroup[]
|
||||
/** True when accounts or payees were dropped by the display caps. */
|
||||
truncated: boolean
|
||||
/** Sum of the shown weights, floored at 1 so it is always a safe divisor. */
|
||||
totalWeight: number
|
||||
/** Largest single-entity magnitude among the shown groups, floored at 1. */
|
||||
maxMagnitude: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One entity's magnitude in the ring's unit.
|
||||
*
|
||||
* `total_amount` is whole kronor (`round(...)::bigint` in the RPC), so summing
|
||||
* these is exact integer arithmetic and needs no öre rounding. Negative totals
|
||||
* are clamped to 0 rather than subtracted from a sibling: this drives geometry,
|
||||
* and a negative radius is not a thing.
|
||||
*/
|
||||
export function entityMagnitude(entity: DeepEntity, basis: RingBasis): number {
|
||||
if (basis === 'volume') return Math.max(entity.occurrences, 0)
|
||||
return Math.max(entity.total_amount, 0)
|
||||
}
|
||||
|
||||
function sumMagnitude(items: DeepEntity[], basis: RingBasis): number {
|
||||
return items.reduce((sum, item) => sum + entityMagnitude(item, basis), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups entities by their dominant account and picks what the ring shows.
|
||||
*
|
||||
* Deterministic end to end: ties break on account number rather than on input
|
||||
* order, so the demo lays out identically on every load.
|
||||
*/
|
||||
export function selectAccountRing(
|
||||
entities: DeepEntity[],
|
||||
options: { maxAccounts?: number; maxPerAccount?: number } = {},
|
||||
): RingSelection {
|
||||
const maxAccounts = options.maxAccounts ?? MAX_ACCOUNTS
|
||||
const maxPerAccount = options.maxPerAccount ?? MAX_PER_ACCOUNT
|
||||
|
||||
const byAccount = new Map<string, DeepEntity[]>()
|
||||
for (const entity of entities) {
|
||||
const account = entity.dominant_account_number
|
||||
if (!account) continue
|
||||
const bucket = byAccount.get(account)
|
||||
if (bucket) bucket.push(entity)
|
||||
else byAccount.set(account, [entity])
|
||||
}
|
||||
|
||||
const buckets = [...byAccount.entries()]
|
||||
|
||||
// The unit decision, made once for the whole ring. An account with no usable
|
||||
// amount would otherwise be weighted in bookings while its siblings are
|
||||
// weighted in kronor, and the two get summed into one denominator below.
|
||||
const basis: RingBasis =
|
||||
buckets.length > 0 && buckets.every(([, items]) => sumMagnitude(items, 'amount') > 0)
|
||||
? 'amount'
|
||||
: 'volume'
|
||||
|
||||
let groups: RingGroup[] = buckets.map(([number, items]) => ({
|
||||
number,
|
||||
items: [...items].sort((a, b) => entityMagnitude(b, basis) - entityMagnitude(a, basis)),
|
||||
weight: sumMagnitude(items, basis),
|
||||
}))
|
||||
|
||||
// Rank to decide what to show, then lay out in account-number order.
|
||||
groups.sort((a, b) => b.weight - a.weight || a.number.localeCompare(b.number))
|
||||
const accountCount = groups.length
|
||||
groups = groups.slice(0, maxAccounts)
|
||||
let truncated = accountCount > groups.length
|
||||
for (const group of groups) {
|
||||
if (group.items.length > maxPerAccount) {
|
||||
truncated = true
|
||||
group.items = group.items.slice(0, maxPerAccount)
|
||||
}
|
||||
}
|
||||
groups.sort((a, b) => a.number.localeCompare(b.number))
|
||||
|
||||
const totalWeight = groups.reduce((sum, group) => sum + group.weight, 0) || 1
|
||||
const maxMagnitude = Math.max(
|
||||
...groups.flatMap((group) => group.items.map((item) => entityMagnitude(item, basis))),
|
||||
1,
|
||||
)
|
||||
|
||||
return { basis, groups, truncated, totalWeight, maxMagnitude }
|
||||
}
|
||||
@@ -138,7 +138,14 @@ export default function ApprovalCard({
|
||||
setState('pending')
|
||||
return
|
||||
}
|
||||
throw new Error(errorText(body.error) || `HTTP ${res.status}`)
|
||||
// Map the parsed body plus the status, never `new Error(body.error)`:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
setState('error')
|
||||
setErrorMessage(getUserErrorMessage(body, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
// Best-effort deep-link to the created artifact in the success state.
|
||||
if (body?.data) setCommitResult(body.data)
|
||||
@@ -163,8 +170,10 @@ export default function ApprovalCard({
|
||||
body: JSON.stringify({ account_numbers: accountsToActivate }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
||||
throw new Error(body.error || 'Kunde inte aktivera kontona.')
|
||||
const body = await res.json().catch(() => null)
|
||||
setState('error')
|
||||
setErrorMessage(getUserErrorMessage(body, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
setAccountsToActivate(null)
|
||||
await handleCommit()
|
||||
@@ -196,8 +205,10 @@ export default function ApprovalCard({
|
||||
: {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
const body = await res.json().catch(() => null)
|
||||
setState('error')
|
||||
setErrorMessage(getUserErrorMessage(body, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
setShowRejectForm(false)
|
||||
setState('rejected')
|
||||
@@ -641,12 +652,6 @@ function CategorizeTransactionPreview({
|
||||
|
||||
// Pull a human message out of an API error body that may be either a bare
|
||||
// string ({ error: "…" }) or the structured envelope ({ error: { message } }).
|
||||
function errorText(error: string | { message?: string } | undefined): string | null {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && typeof error.message === 'string') return error.message
|
||||
return null
|
||||
}
|
||||
|
||||
function prettyCategory(value: string | undefined): string {
|
||||
if (!value) return '(saknas)'
|
||||
return CATEGORY_OPTIONS.find((o) => o.value === value)?.label ?? value
|
||||
|
||||
+318
-252
@@ -6,12 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsInput,
|
||||
SettingsRow,
|
||||
SettingsSeg,
|
||||
SettingsSelect,
|
||||
SettingsTextarea,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { ChevronDown, Loader2, Lock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -34,21 +38,37 @@ const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const
|
||||
// lib/api/schemas.ts (25 | 12 | 6 | 0).
|
||||
const VAT_RATES = [25, 12, 6, 0] as const
|
||||
|
||||
/** Money is rounded to öre with arithmetic, never toFixed (CLAUDE.md rule 6). */
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
// SettingsInput is a bare input, so it does not carry the wheel guard that
|
||||
// components/ui/input.tsx applies to type="number". Without it, scrolling the
|
||||
// page with the cursor over a focused price field silently edits the amount.
|
||||
function blurOnWheel(e: React.WheelEvent<HTMLInputElement>) {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
|
||||
interface ArticleFormProps {
|
||||
onSubmit: (data: CreateArticleInput) => Promise<void>
|
||||
isLoading: boolean
|
||||
initialData?: Partial<CreateArticleInput>
|
||||
/** Closes the host dialog. When omitted the cancel button is not rendered. */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export default function ArticleForm({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
initialData,
|
||||
onCancel,
|
||||
}: ArticleFormProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
const t = useTranslations('form_article')
|
||||
const tCommon = useTranslations('common')
|
||||
// Active class 1-3 posting accounts for the combobox. The combobox accepts
|
||||
// unknown 4-digit numbers optimistically: the API answers with
|
||||
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
|
||||
@@ -117,12 +137,14 @@ export default function ArticleForm({
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id])
|
||||
// Open the advanced section by default when it already holds data, so an
|
||||
// edit never hides a value the user previously set.
|
||||
const [advancedOpen, setAdvancedOpen] = useState(
|
||||
|
||||
// Open "Fler fält" by default when it already holds data, so an edit never
|
||||
// hides a value the user previously set. Currency and posting account are no
|
||||
// longer in here: both are permanent rows.
|
||||
const [moreOpen, setMoreOpen] = useState(
|
||||
Boolean(
|
||||
(initialData?.currency && initialData.currency !== 'SEK') ||
|
||||
initialData?.revenue_account ||
|
||||
initialData?.article_number ||
|
||||
initialData?.name_en ||
|
||||
initialData?.cost_price != null ||
|
||||
initialData?.ean ||
|
||||
initialData?.housework_type ||
|
||||
@@ -186,6 +208,29 @@ export default function ArticleForm({
|
||||
})
|
||||
|
||||
const type = watch('type')
|
||||
const watchedName = watch('name')
|
||||
const watchedUnit = watch('unit')
|
||||
const watchedPrice = watch('price_excl_vat')
|
||||
const watchedVat = watch('vat_rate')
|
||||
const watchedCurrency = watch('currency')
|
||||
const watchedAccount = watch('revenue_account')
|
||||
const watchedNumber = watch('article_number')
|
||||
|
||||
// The summary strip: what this article becomes as a line on an invoice.
|
||||
// Display only, so it never posts anything, but it is the reason currency is
|
||||
// impossible to miss here.
|
||||
const price = Number.isFinite(watchedPrice) ? Number(watchedPrice) : 0
|
||||
const effectiveVat = vatRegistered ? Number(watchedVat) || 0 : 0
|
||||
const vatAmount = round2((price * effectiveVat) / 100)
|
||||
const totalInclVat = round2(price + vatAmount)
|
||||
const currency = watchedCurrency || 'SEK'
|
||||
|
||||
// Keep the article's own currency selectable even before the fetch resolves
|
||||
// or if it has since been deactivated.
|
||||
const currencyCodes = useMemo(() => {
|
||||
const codes = currencies.map((c) => c.code)
|
||||
return codes.includes(currency) ? codes : [currency, ...codes]
|
||||
}, [currencies, currency])
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
onSubmit({
|
||||
@@ -205,283 +250,304 @@ export default function ArticleForm({
|
||||
})
|
||||
}
|
||||
|
||||
const fieldError = (message?: string) =>
|
||||
message ? <p className="basis-full text-xs text-destructive">{message}</p> : null
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Type + article number */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('type_label')}</Label>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<SettingsGroup label={t('group_article')}>
|
||||
<SettingsRow label={t('type_label')}>
|
||||
<Controller
|
||||
name="type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('type_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vara">{t('type_vara')}</SelectItem>
|
||||
<SelectItem value="tjanst">{t('type_tjanst')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SettingsSeg
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
aria-label={t('type_label')}
|
||||
options={[
|
||||
{ value: 'tjanst', label: t('type_tjanst') },
|
||||
{ value: 'vara', label: t('type_vara') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="article_number">{t('number_label')}</Label>
|
||||
<Input
|
||||
id="article_number"
|
||||
className="tabular-nums"
|
||||
{...register('article_number')}
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('name_label')} htmlFor="article-name" align="baseline">
|
||||
<SettingsInput
|
||||
id="article-name"
|
||||
placeholder={t('name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.article_number ? (
|
||||
<p className="text-sm text-destructive">{errors.article_number.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('number_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{fieldError(errors.name?.message)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">{t('name_label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t('name_placeholder')}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<SettingsRow label={t('price_label')} htmlFor="article-price" align="baseline">
|
||||
<span className="flex items-center gap-2">
|
||||
<SettingsInput
|
||||
id="article-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-28 flex-none tabular-nums"
|
||||
onWheel={blurOnWheel}
|
||||
{...register('price_excl_vat', { valueAsNumber: true })}
|
||||
/>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsSelect
|
||||
aria-label={t('currency_label')}
|
||||
className="text-muted-foreground"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
>
|
||||
{currencyCodes.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{fieldError(errors.price_excl_vat?.message)}
|
||||
</SettingsRow>
|
||||
|
||||
{/* English name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_en">{t('name_en_label')}</Label>
|
||||
<Input
|
||||
id="name_en"
|
||||
placeholder={t('name_en_placeholder')}
|
||||
{...register('name_en')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('name_en_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Unit + price + VAT (moms hidden for non-momsregistrerade) */}
|
||||
<div className={`grid grid-cols-1 gap-4 ${vatRegistered ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('unit_label')}</Label>
|
||||
{/* Enhet closes the group when the company charges no moms, so the
|
||||
group never ends on a dangling hairline. */}
|
||||
<SettingsRow label={t('unit_label')} htmlFor="article-unit" borderless={!vatRegistered}>
|
||||
<Controller
|
||||
name="unit"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNITS.map((u) => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SettingsSelect
|
||||
id="article-unit"
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
>
|
||||
{UNITS.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price_excl_vat">{t('price_label')}</Label>
|
||||
<Input
|
||||
id="price_excl_vat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="tabular-nums"
|
||||
{...register('price_excl_vat', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.price_excl_vat && (
|
||||
<p className="text-sm text-destructive">{errors.price_excl_vat.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
{vatRegistered && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('vat_rate_label')}</Label>
|
||||
<SettingsRow label={t('vat_rate_label')} htmlFor="article-vat" borderless>
|
||||
<Controller
|
||||
name="vat_rate"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsSelect
|
||||
id="article-vat"
|
||||
value={String(field.value)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
>
|
||||
{VAT_RATES.map((rate) => (
|
||||
<option key={rate} value={String(rate)}>
|
||||
{rate} %
|
||||
</option>
|
||||
))}
|
||||
</SettingsSelect>
|
||||
)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label={t('group_accounting')}>
|
||||
<SettingsRow
|
||||
label={t('revenue_account_label')}
|
||||
align="baseline"
|
||||
borderless
|
||||
help={t('revenue_account_hint')}
|
||||
>
|
||||
<Controller
|
||||
name="vat_rate"
|
||||
name="revenue_account"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => { if (v) field.onChange(Number(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_RATES.map((rate) => (
|
||||
<SelectItem key={rate} value={String(rate)}>{rate} %</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountCombobox
|
||||
flat
|
||||
value={field.value || ''}
|
||||
accounts={postingAccounts}
|
||||
onChange={field.onChange}
|
||||
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{fieldError(errors.revenue_account?.message)}
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Advanced (collapsible) */}
|
||||
<div className="pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-expanded={advancedOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform duration-200', advancedOpen && 'rotate-180')}
|
||||
/>
|
||||
{t('advanced_section')}
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<SettingsGroup label={t('more_fields')}>
|
||||
<SettingsRow
|
||||
label={t('number_label')}
|
||||
htmlFor="article-number"
|
||||
align="baseline"
|
||||
help={t('number_hint')}
|
||||
>
|
||||
<SettingsInput id="article-number" className="tabular-nums" {...register('article_number')} />
|
||||
{fieldError(errors.article_number?.message)}
|
||||
</SettingsRow>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="space-y-4 pt-4">
|
||||
{/* Revenue account */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('revenue_account_label')}</Label>
|
||||
<SettingsRow
|
||||
label={t('name_en_label')}
|
||||
htmlFor="article-name-en"
|
||||
align="baseline"
|
||||
help={t('name_en_hint')}
|
||||
>
|
||||
<SettingsInput
|
||||
id="article-name-en"
|
||||
placeholder={t('name_en_placeholder')}
|
||||
{...register('name_en')}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t('cost_price_label')}
|
||||
htmlFor="article-cost"
|
||||
align="baseline"
|
||||
help={t('cost_price_hint')}
|
||||
>
|
||||
<SettingsInput
|
||||
id="article-cost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-28 flex-none tabular-nums"
|
||||
onWheel={blurOnWheel}
|
||||
{...register('cost_price', {
|
||||
setValueAs: (v) => (v === '' || v == null ? undefined : Number(v)),
|
||||
})}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow label={t('ean_label')} htmlFor="article-ean" align="baseline">
|
||||
<SettingsInput
|
||||
id="article-ean"
|
||||
placeholder={t('ean_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register('ean')}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
{type === 'tjanst' && (
|
||||
<SettingsRow
|
||||
label={t('housework_label')}
|
||||
htmlFor="article-housework"
|
||||
help={t('housework_hint')}
|
||||
>
|
||||
<Controller
|
||||
name="revenue_account"
|
||||
name="housework_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AccountCombobox
|
||||
<SettingsSelect
|
||||
id="article-housework"
|
||||
value={field.value || ''}
|
||||
accounts={postingAccounts}
|
||||
onChange={field.onChange}
|
||||
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)}
|
||||
/>
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t('housework_none')}</option>
|
||||
<option value="ROT">{t('housework_rot')}</option>
|
||||
<option value="RUT">{t('housework_rut')}</option>
|
||||
</SettingsSelect>
|
||||
)}
|
||||
/>
|
||||
{errors.revenue_account && (
|
||||
<p className="text-sm text-destructive">{errors.revenue_account.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{/* Cost price */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost_price">{t('cost_price_label')}</Label>
|
||||
<Input
|
||||
id="cost_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="tabular-nums"
|
||||
{...register('cost_price', {
|
||||
setValueAs: (v) => (v === '' || v == null ? undefined : Number(v)),
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('cost_price_hint')}</p>
|
||||
</div>
|
||||
<SettingsRow label={t('notes_label')} htmlFor="article-notes" align="baseline" borderless>
|
||||
<SettingsTextarea
|
||||
id="article-notes"
|
||||
rows={2}
|
||||
placeholder={t('notes_placeholder')}
|
||||
{...register('notes')}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
|
||||
{/* Currency */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('currency_label')}</Label>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
// Always keep the current value selectable, even before the
|
||||
// fetch resolves or if it's since been deactivated.
|
||||
const codes = currencies.map((c) => c.code)
|
||||
const options = codes.includes(field.value)
|
||||
? codes
|
||||
: [field.value, ...codes]
|
||||
return (
|
||||
<Select value={field.value} onValueChange={(v) => { if (v) field.onChange(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('currency_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* EAN */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ean">{t('ean_label')}</Label>
|
||||
<Input
|
||||
id="ean"
|
||||
placeholder={t('ean_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register('ean')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Housework type (tjänst only) */}
|
||||
{type === 'tjanst' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('housework_label')}</Label>
|
||||
<Controller
|
||||
name="housework_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value || 'none'}
|
||||
onValueChange={(v) => field.onChange(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('housework_placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('housework_none')}</SelectItem>
|
||||
<SelectItem value="ROT">{t('housework_rot')}</SelectItem>
|
||||
<SelectItem value="RUT">{t('housework_rut')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('housework_hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">{t('notes_label')}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder={t('notes_placeholder')}
|
||||
{...register('notes')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-1 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen((open) => !open)}
|
||||
className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
aria-expanded={moreOpen}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-200', moreOpen && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('more_fields')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('submit_saving')}
|
||||
</>
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{t('submit_save')}
|
||||
</>
|
||||
) : (
|
||||
t('submit_save')
|
||||
{/* The line this article becomes. Full-bleed against the dialog's p-6. */}
|
||||
<div className="-mx-6 mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 border-t border-border bg-muted/60 px-6 py-3 text-xs text-muted-foreground">
|
||||
<span className="max-w-[46%] break-words font-display text-[15px] leading-snug text-foreground">
|
||||
{watchedName || t('summary_unnamed')}
|
||||
</span>
|
||||
<span aria-hidden="true" className="opacity-50">·</span>
|
||||
<span className="tabular-nums">1 {watchedUnit}</span>
|
||||
<span aria-hidden="true" className="opacity-50">·</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(price, currency)}</span>
|
||||
{effectiveVat > 0 && (
|
||||
<>
|
||||
<span>{t('summary_plus_vat')}</span>
|
||||
<span className="tabular-nums text-foreground">{formatCurrency(vatAmount, currency)}</span>
|
||||
</>
|
||||
)}
|
||||
{watchedAccount ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">·</span>
|
||||
<span className="tabular-nums">
|
||||
{t('summary_booked_on')} {watchedAccount}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="ml-auto font-display text-[17px] tabular-nums text-foreground">
|
||||
{formatCurrency(totalInclVat, currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="-mx-6 -mb-6 flex flex-wrap items-center gap-3 px-6 pb-6 pt-4">
|
||||
{!watchedNumber && (
|
||||
<p className="max-w-[30ch] text-xs leading-relaxed text-muted-foreground">
|
||||
{t('number_hint')}
|
||||
</p>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('submit_saving')}
|
||||
</>
|
||||
) : !canWrite ? (
|
||||
<>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
{t('submit_save')}
|
||||
</>
|
||||
) : (
|
||||
t('submit_save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline custom-account creation (renders in a portal, outside the form).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
|
||||
import {
|
||||
buildAccountIndex,
|
||||
@@ -44,9 +45,15 @@ interface AccountComboboxProps {
|
||||
// Optional always-visible label for compact editors where the selected
|
||||
// account name must remain readable after the dropdown closes.
|
||||
selectedName?: string
|
||||
// Renders the trigger in the flat row language (SettingsRows): no box, a
|
||||
// dashed underline on hover, solid on focus. For hosts that compose
|
||||
// SettingsRow, where a bordered Input would be the only box in the form.
|
||||
// The dropdown also narrows to the row's width so it cannot overflow a
|
||||
// dialog. Default (false) keeps the boxed Input every existing caller uses.
|
||||
flat?: boolean
|
||||
}
|
||||
|
||||
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false, selectedName }: AccountComboboxProps) {
|
||||
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false, selectedName, flat = false }: AccountComboboxProps) {
|
||||
const [search, setSearch] = useState(value)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
@@ -242,21 +249,35 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
|
||||
const showSelectedName = Boolean(selectedName && value && search === value)
|
||||
|
||||
// In flat mode the dropdown follows the trigger's width instead of forcing
|
||||
// 34rem: inside a SettingsRow that fixed width would overflow the dialog.
|
||||
const listWidthClass = flat ? 'w-full min-w-0' : 'min-w-[24rem] w-[max(100%,34rem)]'
|
||||
|
||||
const triggerProps = {
|
||||
ref: setInputRef,
|
||||
value: search,
|
||||
onChange: handleInputChange,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onKeyDown: handleKeyDown,
|
||||
placeholder: 'Sök konto…',
|
||||
autoComplete: 'off',
|
||||
disabled,
|
||||
'aria-describedby': showSelectedName ? selectedNameId : undefined,
|
||||
className: flat
|
||||
? cn(
|
||||
'min-w-0 flex-1 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 font-mono text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground/60 hover:border-border',
|
||||
'focus:border-solid focus:border-foreground/50 focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
|
||||
className,
|
||||
)
|
||||
: `font-mono ${className ?? ''}`.trim(),
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
ref={setInputRef}
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Sök konto…"
|
||||
className={`font-mono ${className ?? ''}`.trim()}
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
aria-describedby={showSelectedName ? selectedNameId : undefined}
|
||||
/>
|
||||
{flat ? <input {...triggerProps} /> : <Input {...triggerProps} />}
|
||||
|
||||
{showSelectedName ? (
|
||||
<p id={selectedNameId} className="mt-1 break-words px-1 text-sm leading-snug text-foreground">
|
||||
@@ -268,7 +289,10 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
{isOpen && !disabled && flatList.length > 0 && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
|
||||
className={cn(
|
||||
'absolute z-50 top-full left-0 mt-1 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md',
|
||||
listWidthClass,
|
||||
)}
|
||||
>
|
||||
{groupedAccounts.map((group) => (
|
||||
<div key={group.className}>
|
||||
@@ -311,7 +335,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
|
||||
|
||||
{/* Empty state */}
|
||||
{isOpen && !disabled && search.trim() && flatList.length === 0 && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] rounded-md border border-input bg-card shadow-md p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 top-full left-0 mt-1 rounded-md border border-input bg-card shadow-md p-3',
|
||||
listWidthClass,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hittade inget konto som matchar.
|
||||
</p>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
computeInstallmentAmounts,
|
||||
countCalendarMonths,
|
||||
} from '@/lib/bookkeeping/accruals/compute'
|
||||
import { shouldShowK2AccrualHint } from '@/components/bookkeeping/accrual-k2-hint'
|
||||
import type { AccrualDirection } from '@/types'
|
||||
|
||||
export interface AccrualFormValue {
|
||||
@@ -52,14 +53,24 @@ const BALANCE_ACCOUNT_OPTIONS: Record<AccrualDirection, Array<{ value: string; l
|
||||
export default function AccrualPeriodControl({
|
||||
direction,
|
||||
amount,
|
||||
currency,
|
||||
exchangeRate,
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
idPrefix,
|
||||
}: {
|
||||
direction: AccrualDirection
|
||||
/** Net line amount (ex VAT): drives the preview and the K2 hint. */
|
||||
/** Net line amount (ex VAT), in `currency`: drives the preview and the K2 hint. */
|
||||
amount: number
|
||||
/** ISO code of `amount`. Missing is treated as SEK (the editors' default). */
|
||||
currency?: string | null
|
||||
/**
|
||||
* SEK per unit of `currency`. Only the supplier-invoice form has one; the
|
||||
* customer-invoice editor carries no rate, so the K2 hint stays hidden on
|
||||
* its foreign-currency lines instead of comparing kronor to euros.
|
||||
*/
|
||||
exchangeRate?: number | null
|
||||
value: AccrualFormValue
|
||||
onChange: (next: AccrualFormValue) => void
|
||||
onRemove: () => void
|
||||
@@ -81,7 +92,7 @@ export default function AccrualPeriodControl({
|
||||
const amounts = computeInstallmentAmounts(amount, months)
|
||||
preview = t('preview', {
|
||||
months,
|
||||
amount: formatCurrency(amounts[0]),
|
||||
amount: formatCurrency(amounts[0], currency || 'SEK'),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
@@ -90,7 +101,11 @@ export default function AccrualPeriodControl({
|
||||
}
|
||||
}
|
||||
|
||||
const showK2Hint = amount > 0 && amount < 5000
|
||||
// K2's 5 000 kr vasentlighetsgrans is measured in kronor, and it is a
|
||||
// simplification the company may use, not an obligation. So when the line
|
||||
// is in a foreign currency and no rate is available, show nothing at all
|
||||
// rather than compare the raw foreign amount against a SEK threshold.
|
||||
const showK2Hint = shouldShowK2AccrualHint({ amount, currency, exchangeRate })
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
|
||||
|
||||
@@ -93,8 +93,15 @@ export function AddAccountDialog({
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte skapa kontot')
|
||||
// Map the response itself, not `new Error(data.error)`: the route
|
||||
// answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", throwing away the
|
||||
// route's own Swedish reason. Passing the parsed body plus the status
|
||||
// resolves all three shapes (envelope, bare string, no body).
|
||||
const body = await response.json().catch(() => null)
|
||||
setError(getUserErrorMessage(body, { statusCode: response.status }))
|
||||
return
|
||||
}
|
||||
|
||||
const { data: createdAccount } = await response.json() as { data: BASAccount }
|
||||
|
||||
@@ -241,8 +241,16 @@ export default function ChartOfAccountsManager() {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || t('toast_delete_failed'))
|
||||
// The route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`. `new Error(data.error)` would
|
||||
// stringify that object to "[object Object]" and the reason would be
|
||||
// lost; hand getErrorMessage the parsed body plus the status instead.
|
||||
const body = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('toast_account_deleted'), description: `${account.account_number} ${account.account_name}` })
|
||||
await refreshAll()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Lock } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { computeSuggestedPeriod } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
import { fiscalPeriodAdvisoryText } from '@/lib/bookkeeping/fiscal-period-warnings'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
interface Props {
|
||||
@@ -25,12 +28,13 @@ interface Props {
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
/** A prior period that must be locked before a new fiscal year can be created. */
|
||||
interface BlockingPeriod {
|
||||
id: string
|
||||
/** A räkenskapsår that was just created, together with the non-blocking
|
||||
* advisory the route attached to the 200. Only set when there is one. */
|
||||
interface CreatedWithAdvisory {
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
advisory: string
|
||||
}
|
||||
|
||||
/** Read a user-facing message from either a legacy string error or the
|
||||
@@ -45,16 +49,23 @@ function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): str
|
||||
|
||||
export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) {
|
||||
const { toast } = useToast()
|
||||
// The form copy below is pre-existing hardcoded Swedish and is left as it
|
||||
// stands (out of scope here). Every string added for the created-state is
|
||||
// keyed in both messages/sv.json and messages/en.json. The advisory itself is
|
||||
// not keyed: the route emits one Swedish sentence and no English twin, and
|
||||
// bokslut/räkenskapsår domain copy stays Swedish in both locales anyway
|
||||
// (.claude/rules/i18n.md).
|
||||
const t = useTranslations('bookkeeping')
|
||||
const tCommon = useTranslations('common')
|
||||
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
|
||||
|
||||
const [name, setName] = useState(suggested.name)
|
||||
const [periodStart, setPeriodStart] = useState(suggested.period_start)
|
||||
const [periodEnd, setPeriodEnd] = useState(suggested.period_end)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isLocking, setIsLocking] = useState(false)
|
||||
// Set when creation is blocked because a prior räkenskapsår is still open.
|
||||
// The user can lock these inline and retry without leaving the dialog.
|
||||
const [blockingPeriods, setBlockingPeriods] = useState<BlockingPeriod[]>([])
|
||||
// Set when the period was created AND the route attached an advisory. The
|
||||
// dialog then holds on a done-state so the sentence is actually read.
|
||||
const [created, setCreated] = useState<CreatedWithAdvisory | null>(null)
|
||||
|
||||
// Reset form when suggested values change (dialog reopened with new date)
|
||||
const [lastSuggested, setLastSuggested] = useState(suggested)
|
||||
@@ -63,7 +74,18 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
setPeriodStart(suggested.period_start)
|
||||
setPeriodEnd(suggested.period_end)
|
||||
setLastSuggested(suggested)
|
||||
setBlockingPeriods([])
|
||||
setCreated(null)
|
||||
}
|
||||
|
||||
// Close, and refetch in the parent if this session created a period. The
|
||||
// refetch is deferred to here on the advisory path on purpose: it swaps the
|
||||
// `periods` prop, which resets the form above, and that would pull the
|
||||
// advisory off screen before it was read.
|
||||
const handleClose = () => {
|
||||
const didCreate = created !== null
|
||||
setCreated(null)
|
||||
onOpenChange(false)
|
||||
if (didCreate) onCreated()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -78,28 +100,25 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
const result = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
const err = result?.error
|
||||
// Blocked by an open prior year: surface an inline "lås och försök
|
||||
// igen" path instead of a dead-end toast.
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
err.code === 'PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS'
|
||||
) {
|
||||
const blocking = (err.details?.blockingPeriods ?? []) as BlockingPeriod[]
|
||||
setBlockingPeriods(blocking)
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: 'Kunde inte skapa räkenskapsår',
|
||||
description: errorMessage(err),
|
||||
description: errorMessage(result?.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// The 200 may carry non-blocking advisories (today: a prior räkenskapsår
|
||||
// still open, which is the normal state while the bokslut runs). The
|
||||
// period WAS created, so this is information, never a failure: show the
|
||||
// done-state with one ochre sentence rather than flashing a toast past.
|
||||
const advisory = fiscalPeriodAdvisoryText(result)
|
||||
if (advisory) {
|
||||
setCreated({ name, periodStart, periodEnd, advisory })
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` })
|
||||
setBlockingPeriods([])
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
} catch {
|
||||
@@ -113,108 +132,68 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
|
||||
}
|
||||
}
|
||||
|
||||
// Lock each blocking prior year (reversible locked_at), then retry creation.
|
||||
const handleLockAndRetry = async () => {
|
||||
setIsLocking(true)
|
||||
try {
|
||||
for (const p of blockingPeriods) {
|
||||
const res = await fetch(`/api/bookkeeping/fiscal-periods/${p.id}/lock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
// An already-locked period is fine: keep going.
|
||||
if (body?.error?.code === 'PERIOD_LOCK_ALREADY_LOCKED') continue
|
||||
toast({
|
||||
title: `Kunde inte låsa ${p.name}`,
|
||||
description: errorMessage(body?.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
setBlockingPeriods([])
|
||||
await handleCreate()
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Kunde inte låsa räkenskapsåret',
|
||||
description: 'Ett nätverksfel uppstod. Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLocking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isBlocked = blockingPeriods.length > 0
|
||||
const busy = isSubmitting || isLocking
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : handleClose())}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa räkenskapsår</DialogTitle>
|
||||
<DialogDescription>
|
||||
Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{created ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('period_created_title')}</DialogTitle>
|
||||
<DialogDescription className="tabular-nums">
|
||||
{t('period_created_range', {
|
||||
name: created.name,
|
||||
start: created.periodStart,
|
||||
end: created.periodEnd,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Namn</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Startdatum</Label>
|
||||
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slutdatum</Label>
|
||||
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Attention is one ochre sentence, not a banner (UI-migration
|
||||
convention 6). The text is the route's own Swedish domain copy,
|
||||
which stays Swedish in both locales. */}
|
||||
<AttnLine>{created.advisory}</AttnLine>
|
||||
|
||||
{isBlocked && (
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
|
||||
<Lock className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Föregående räkenskapsår är öppet</p>
|
||||
<p className="text-muted-foreground">
|
||||
Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.
|
||||
Låsningen är vändbar: du kan låsa upp året igen för att bokföra
|
||||
bokslutsposter.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>{tCommon('close')}</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skapa räkenskapsår</DialogTitle>
|
||||
<DialogDescription>
|
||||
Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Namn</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Startdatum</Label>
|
||||
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Slutdatum</Label>
|
||||
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-muted-foreground">
|
||||
{blockingPeriods.map((p) => (
|
||||
<li key={p.id} className="tabular-nums">
|
||||
{p.name} ({p.period_start} till {p.period_end})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Avbryt
|
||||
</Button>
|
||||
{isBlocked ? (
|
||||
<Button onClick={handleLockAndRetry} disabled={busy}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{blockingPeriods.length > 1 ? 'Lås åren och skapa' : 'Lås året och skapa'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleCreate} disabled={busy || !name || !periodStart || !periodEnd}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Skapa
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isSubmitting || !name || !periodStart || !periodEnd}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Skapa
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ interface FiscalPeriodDateFieldsProps {
|
||||
/**
|
||||
* Shared first-fiscal-period date entry: day-level start (native date input),
|
||||
* caller-provided end-date control, and a Swedish summary card with inline
|
||||
* validation errors (6-18 months, EF calendar-year rule, last-day-of-month).
|
||||
* validation errors (max 18 months, EF calendar-year rule, last-day-of-month).
|
||||
*
|
||||
* Used by onboarding Step 3 and the settings FiscalPeriodEditor so the two
|
||||
* screens stay in lockstep.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -10,6 +11,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags, BookmarkPlus } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -33,9 +35,15 @@ import {
|
||||
throwOnStructuredError,
|
||||
} from '@/lib/hooks/use-submit-with-account-activation'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
linkDocuments,
|
||||
formatFailedDocumentNames,
|
||||
type DocumentLinkFailure,
|
||||
} from '@/lib/documents/link-documents'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveFxLineSlot } from '@/lib/bookkeeping/fx-line-slot'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
@@ -125,6 +133,7 @@ export default function JournalEntryForm({
|
||||
}: Props) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('journal_form')
|
||||
// Reused only for the bilingual entity-type labels the shared TemplateForm
|
||||
@@ -862,42 +871,79 @@ export default function JournalEntryForm({
|
||||
// Inner submit: builds payload, POSTs, throws a structured error on failure
|
||||
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
|
||||
const postJournalEntry = useCallback(async () => {
|
||||
let currencyMetaApplied = false
|
||||
const entryLines: CreateJournalEntryLineInput[] = lines
|
||||
.filter((l) => l.account_number && (l.debit_amount || l.credit_amount))
|
||||
.map((l) => {
|
||||
const base: CreateJournalEntryLineInput = {
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
line_description: l.line_description || undefined,
|
||||
}
|
||||
const submittableLines = lines.filter(
|
||||
(l) => l.account_number && (l.debit_amount || l.credit_amount)
|
||||
)
|
||||
|
||||
if (l.dimensions) {
|
||||
const dims = Object.fromEntries(
|
||||
Object.entries(l.dimensions).filter(([, v]) => typeof v === 'string' && v.trim() !== '')
|
||||
)
|
||||
if (Object.keys(dims).length > 0) base.dimensions = dims
|
||||
}
|
||||
// PRE-PASS: decide which single line carries the entry's FX metadata BEFORE
|
||||
// any line is built, so the answer cannot depend on line order. Deciding
|
||||
// inside the map meant the "already applied" latch only closed once the
|
||||
// loop had reached a hydrated FX line, so an agent-created EUR draft edited
|
||||
// here could end up with TWO lines claiming the same foreign amount; and
|
||||
// the old test (first account starting with '19') picked the SEK leg of a
|
||||
// EUR/SEK växling and dropped the metadata entirely on entries with no 19xx
|
||||
// line at all. lib/bookkeeping/fx-line-slot.ts states the rule.
|
||||
const fxSlot = resolveFxLineSlot(
|
||||
submittableLines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
currency: l.currency,
|
||||
})),
|
||||
{ entryCurrency, exchangeRate: rate, foreignAmount: computedForeignAmount }
|
||||
)
|
||||
|
||||
if (l.tax_code) base.tax_code = l.tax_code
|
||||
|
||||
if (l.currency) {
|
||||
base.currency = l.currency
|
||||
if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency
|
||||
if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate
|
||||
// A line already carrying FX metadata (hydrated edit) IS the FX
|
||||
// line: don't also stamp the fallback meta onto another 19xx line.
|
||||
if (l.currency !== 'SEK') currencyMetaApplied = true
|
||||
} else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) {
|
||||
base.currency = entryCurrency
|
||||
base.amount_in_currency = computedForeignAmount
|
||||
base.exchange_rate = rate
|
||||
currencyMetaApplied = true
|
||||
}
|
||||
|
||||
return base
|
||||
if (fxSlot.kind === 'unplaceable') {
|
||||
// `journal_entries` has no currency or exchange_rate column: a rate that
|
||||
// is not written onto a line is unrecoverable, and the verifikat would
|
||||
// post looking as if it had always been in SEK. Refuse instead, before
|
||||
// anything is sent, and say which rader are involved.
|
||||
const messageKey =
|
||||
fxSlot.reason === 'ambiguous'
|
||||
? 'fx_unplaceable_ambiguous'
|
||||
: fxSlot.reason === 'currency_conflict'
|
||||
? 'fx_unplaceable_currency_conflict'
|
||||
: 'fx_unplaceable_no_carrier'
|
||||
const message = t(messageKey, {
|
||||
currency: entryCurrency,
|
||||
amount: computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 }),
|
||||
rate: rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 }),
|
||||
accounts: fxSlot.accounts.join(', '),
|
||||
lineCurrency: fxSlot.lineCurrency ?? '',
|
||||
})
|
||||
throw Object.assign(new Error(message), { body: { error: { message } }, status: 400 })
|
||||
}
|
||||
|
||||
const entryLines: CreateJournalEntryLineInput[] = submittableLines.map((l, index) => {
|
||||
const base: CreateJournalEntryLineInput = {
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
line_description: l.line_description || undefined,
|
||||
}
|
||||
|
||||
if (l.dimensions) {
|
||||
const dims = Object.fromEntries(
|
||||
Object.entries(l.dimensions).filter(([, v]) => typeof v === 'string' && v.trim() !== '')
|
||||
)
|
||||
if (Object.keys(dims).length > 0) base.dimensions = dims
|
||||
}
|
||||
|
||||
if (l.tax_code) base.tax_code = l.tax_code
|
||||
|
||||
if (l.currency) {
|
||||
// The line speaks for itself (hydrated draft, agent-created entry).
|
||||
base.currency = l.currency
|
||||
if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency
|
||||
if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate
|
||||
} else if (fxSlot.kind === 'slot' && fxSlot.index === index) {
|
||||
base.currency = entryCurrency
|
||||
base.amount_in_currency = computedForeignAmount
|
||||
base.exchange_rate = rate
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries'
|
||||
// Edit mode PATCHes the draft in place; create mode POSTs (with ?as_draft
|
||||
@@ -926,11 +972,49 @@ export default function JournalEntryForm({
|
||||
}),
|
||||
})
|
||||
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
|
||||
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes])
|
||||
}, [lines, rate, entryCurrency, computedForeignAmount, t, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes])
|
||||
|
||||
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
|
||||
useSubmitWithAccountActivation(postJournalEntry)
|
||||
|
||||
// Attach the uploaded underlag to the entry that was just written. BFL 5 kap
|
||||
// 7 § requires the verifikation to reference its underlag and BFL 7 kap
|
||||
// requires that underlag to be archived with it, but the entry is already
|
||||
// committed when this runs: a failed link cannot be rolled back, only
|
||||
// reported. Files that did not attach are KEPT in the upload zone (clearing
|
||||
// them would erase the user's only pointer to the underlag they believed
|
||||
// was filed); the ones that attached are dropped.
|
||||
const linkUploadedDocuments = async (
|
||||
journalEntryId: string | undefined,
|
||||
): Promise<DocumentLinkFailure[]> => {
|
||||
const targets = uploadedFiles
|
||||
.filter((f) => f.status === 'uploaded' && f.id)
|
||||
.map((f) => ({ documentId: f.id as string, fileName: f.fileName }))
|
||||
if (targets.length === 0) {
|
||||
setUploadedFiles([])
|
||||
return []
|
||||
}
|
||||
if (!journalEntryId) {
|
||||
// No id came back, so there is nothing to link to: report it as a
|
||||
// failure rather than clearing the files behind a success toast.
|
||||
return targets.map((target) => ({
|
||||
documentId: target.documentId,
|
||||
fileName: target.fileName ?? null,
|
||||
status: 0,
|
||||
code: null,
|
||||
reason: null,
|
||||
}))
|
||||
}
|
||||
const { failed } = await linkDocuments(targets, journalEntryId)
|
||||
if (failed.length === 0) {
|
||||
setUploadedFiles([])
|
||||
return []
|
||||
}
|
||||
const failedIds = new Set(failed.map((f) => f.documentId))
|
||||
setUploadedFiles((prev) => prev.filter((f) => f.id != null && failedIds.has(f.id)))
|
||||
return failed
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true)
|
||||
saveAsDraftRef.current = false
|
||||
@@ -938,38 +1022,40 @@ export default function JournalEntryForm({
|
||||
const result = await runSubmit()
|
||||
|
||||
const journalEntryId = result.data?.id ?? result.journal_entry_id
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
toast({
|
||||
title: t('toast_attach_failed_title'),
|
||||
description: t('toast_attach_failed_description', { count: linkFailCount }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
const voucher = formatVoucher(result.data ?? {})
|
||||
const linkFailures = await linkUploadedDocuments(journalEntryId)
|
||||
|
||||
toast({
|
||||
title: t('toast_created_title'),
|
||||
description: t('toast_created_description', { voucher: formatVoucher(result.data ?? {}) }),
|
||||
})
|
||||
if (linkFailures.length > 0) {
|
||||
// The verifikat exists but its underlag does not: say exactly that.
|
||||
// Never the plain "Verifikation skapad", which would leave the user
|
||||
// believing the receipt is on the books.
|
||||
toast({
|
||||
title: t('toast_created_missing_docs_title'),
|
||||
description: t('toast_created_missing_docs_description', {
|
||||
voucher,
|
||||
count: linkFailures.length,
|
||||
files: formatFailedDocumentNames(linkFailures),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
action: journalEntryId ? (
|
||||
<ToastAction
|
||||
altText={t('toast_open_entry')}
|
||||
onClick={() => router.push(`/bookkeeping/${journalEntryId}`)}
|
||||
>
|
||||
{t('toast_open_entry')}
|
||||
</ToastAction>
|
||||
) : undefined,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('toast_created_title'),
|
||||
description: t('toast_created_description', { voucher }),
|
||||
})
|
||||
}
|
||||
setLastPostedMonth(entryDate.slice(0, 7))
|
||||
setShowReview(false)
|
||||
setDescription('')
|
||||
setNotes('')
|
||||
setUploadedFiles([])
|
||||
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
|
||||
setHeaderDims({})
|
||||
setEntryCurrency('SEK')
|
||||
@@ -1036,28 +1122,35 @@ export default function JournalEntryForm({
|
||||
const result = await runSubmit()
|
||||
|
||||
const journalEntryId = result.data?.id ?? result.journal_entry_id
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
// Non-blocking: user can attach underlag from the draft view
|
||||
}
|
||||
}
|
||||
}
|
||||
const linkFailures = await linkUploadedDocuments(journalEntryId)
|
||||
|
||||
toast({
|
||||
title: t('toast_draft_saved_title'),
|
||||
description: t('toast_draft_saved_description'),
|
||||
})
|
||||
if (linkFailures.length > 0) {
|
||||
// Same rule as the posted path: the draft was saved, the underlag was
|
||||
// not attached, and the user is told which files are missing.
|
||||
toast({
|
||||
title: t('toast_draft_missing_docs_title'),
|
||||
description: t('toast_draft_missing_docs_description', {
|
||||
count: linkFailures.length,
|
||||
files: formatFailedDocumentNames(linkFailures),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
action: journalEntryId ? (
|
||||
<ToastAction
|
||||
altText={t('toast_open_entry')}
|
||||
onClick={() => router.push(`/bookkeeping/${journalEntryId}`)}
|
||||
>
|
||||
{t('toast_open_entry')}
|
||||
</ToastAction>
|
||||
) : undefined,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('toast_draft_saved_title'),
|
||||
description: t('toast_draft_saved_description'),
|
||||
})
|
||||
}
|
||||
setDescription('')
|
||||
setNotes('')
|
||||
setUploadedFiles([])
|
||||
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
|
||||
setHeaderDims({})
|
||||
setEntryCurrency('SEK')
|
||||
|
||||
@@ -532,7 +532,15 @@ export default function JournalEntryList() {
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
|
||||
// Resolve via the parsed body plus the status: the route answers
|
||||
// thrown errors with the canonical envelope `{ error: { code,
|
||||
// message } }`, and rendering that object as the toast description
|
||||
// would crash React ("Objects are not valid as a React child").
|
||||
toast({
|
||||
title: t('no_doc_required_save_failed'),
|
||||
description: getErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Reflect the new exemptions locally: triangle → muted "no doc" indicator.
|
||||
@@ -592,7 +600,11 @@ export default function JournalEntryList() {
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast({ title: t('no_doc_required_save_failed'), description: body.error, variant: 'destructive' })
|
||||
toast({
|
||||
title: t('no_doc_required_save_failed'),
|
||||
description: getErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setBulkOpen(false)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, CircleSlash, Lock } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface Props {
|
||||
entryId: string
|
||||
@@ -57,10 +58,14 @@ export default function NoDocRequiredToggle({
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
// Resolve via the parsed body plus the status: the route answers
|
||||
// thrown errors with the canonical envelope `{ error: { code,
|
||||
// message } }`, and rendering that object as the toast description
|
||||
// would crash React ("Objects are not valid as a React child").
|
||||
const body = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: t('no_doc_required_save_failed'),
|
||||
description: body.error,
|
||||
description: getErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
// Roll back UI state on failure: both the toggle AND the reason so
|
||||
|
||||
@@ -172,7 +172,17 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
body: JSON.stringify({ dry_run: false, account_numbers: [...selected] }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(body?.error)
|
||||
if (!res.ok) {
|
||||
// `new Error(body.error)` collapses the canonical envelope
|
||||
// `{ error: { code, message } }` to "[object Object]" and loses the
|
||||
// route's own reason (an account still referenced by a posted entry
|
||||
// is refused with a specific message). Map the body plus the status.
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const deleted: string[] = body?.data?.deleted ?? []
|
||||
const skipped: string[] = [
|
||||
...(body?.data?.skipped ?? []),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
K2_ACCRUAL_THRESHOLD_SEK,
|
||||
resolveAccrualAmountSek,
|
||||
shouldShowK2AccrualHint,
|
||||
} from '@/components/bookkeeping/accrual-k2-hint'
|
||||
|
||||
describe('resolveAccrualAmountSek', () => {
|
||||
it('returns the amount unchanged for SEK', () => {
|
||||
expect(resolveAccrualAmountSek({ amount: 4999, currency: 'SEK' })).toBe(4999)
|
||||
})
|
||||
|
||||
it('treats a missing currency as SEK (the editors default to it)', () => {
|
||||
expect(resolveAccrualAmountSek({ amount: 4999 })).toBe(4999)
|
||||
expect(resolveAccrualAmountSek({ amount: 4999, currency: null })).toBe(4999)
|
||||
expect(resolveAccrualAmountSek({ amount: 4999, currency: '' })).toBe(4999)
|
||||
})
|
||||
|
||||
it('converts a foreign amount with the given rate, rounded to ore', () => {
|
||||
// 500 EUR at 11.5012 = 5750.60 kr, not 5750.5999999999995.
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: 11.5012 }),
|
||||
).toBe(5750.6)
|
||||
})
|
||||
|
||||
it('returns null for a foreign amount with no usable rate', () => {
|
||||
expect(resolveAccrualAmountSek({ amount: 500, currency: 'EUR' })).toBeNull()
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: null }),
|
||||
).toBeNull()
|
||||
// An empty rate field parses to NaN; a cleared one to 0.
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: NaN }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: 0 }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 500, currency: 'EUR', exchangeRate: -11.5 }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a non-finite amount', () => {
|
||||
expect(resolveAccrualAmountSek({ amount: NaN, currency: 'SEK' })).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a lowercase or padded currency code', () => {
|
||||
expect(resolveAccrualAmountSek({ amount: 100, currency: ' sek ' })).toBe(100)
|
||||
expect(
|
||||
resolveAccrualAmountSek({ amount: 100, currency: ' eur ', exchangeRate: 11.5 }),
|
||||
).toBe(1150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowK2AccrualHint', () => {
|
||||
it('hides the hint for a foreign line that is above 5 000 kr once converted', () => {
|
||||
// THE BUG: 500 EUR is ~5 750 kr, i.e. ABOVE the K2 threshold, but the raw
|
||||
// 500 reads as below it and used to surface "under 5 000 kr" wrongly.
|
||||
expect(
|
||||
shouldShowK2AccrualHint({ amount: 500, currency: 'EUR', exchangeRate: 11.5 }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the hint for a foreign line that is genuinely below 5 000 kr', () => {
|
||||
// 400 EUR = 4 600 kr.
|
||||
expect(
|
||||
shouldShowK2AccrualHint({ amount: 400, currency: 'EUR', exchangeRate: 11.5 }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the hint for a foreign line the raw amount would have hidden', () => {
|
||||
// The reverse direction: 6 000 NOK at 0.98 = 5 880 kr... above. At 0.8 it
|
||||
// is 4 800 kr, below the threshold, while the raw 6 000 reads as above.
|
||||
expect(
|
||||
shouldShowK2AccrualHint({ amount: 6000, currency: 'NOK', exchangeRate: 0.8 }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the hint when the SEK value cannot be determined', () => {
|
||||
// The customer-invoice editor has no exchange_rate field at all: never
|
||||
// guess a branch, show nothing.
|
||||
expect(shouldShowK2AccrualHint({ amount: 500, currency: 'EUR' })).toBe(false)
|
||||
expect(shouldShowK2AccrualHint({ amount: 50000, currency: 'EUR' })).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves SEK behaviour exactly as it was', () => {
|
||||
expect(shouldShowK2AccrualHint({ amount: 4999.99, currency: 'SEK' })).toBe(true)
|
||||
expect(shouldShowK2AccrualHint({ amount: 1, currency: 'SEK' })).toBe(true)
|
||||
expect(shouldShowK2AccrualHint({ amount: K2_ACCRUAL_THRESHOLD_SEK, currency: 'SEK' })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(shouldShowK2AccrualHint({ amount: 5001, currency: 'SEK' })).toBe(false)
|
||||
expect(shouldShowK2AccrualHint({ amount: 0, currency: 'SEK' })).toBe(false)
|
||||
// No currency prop at all is the same as SEK.
|
||||
expect(shouldShowK2AccrualHint({ amount: 4999 })).toBe(true)
|
||||
expect(shouldShowK2AccrualHint({ amount: 5000 })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fire on a negative (discount) line', () => {
|
||||
expect(shouldShowK2AccrualHint({ amount: -100, currency: 'SEK' })).toBe(false)
|
||||
expect(
|
||||
shouldShowK2AccrualHint({ amount: -100, currency: 'EUR', exchangeRate: 11.5 }),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* K2 vasentlighetsgrans for periodisering, extracted as a pure helper so it
|
||||
* can be unit-tested (this repo's Vitest runs in a node environment and does
|
||||
* not render components).
|
||||
*
|
||||
* K2 (BFNAR 2016:10) lets a smaller company skip accruing individual
|
||||
* recurring costs below 5 000 kr that do not fluctuate more than 20 % year
|
||||
* over year; personnel costs must always be accrued. It is a simplification
|
||||
* the company MAY use, never an obligation, so the UI copy it drives is
|
||||
* advisory only. That is exactly why it must not fire on an amount we cannot
|
||||
* express in kronor: an advisory nudge based on the wrong unit is worse than
|
||||
* no nudge at all.
|
||||
*
|
||||
* The invoice editors hold line amounts in the invoice's currency. A 500 EUR
|
||||
* line is roughly 5 750 kr and therefore ABOVE the threshold, but a naive
|
||||
* `amount < 5000` reads it as below (and a 400 USD line reads the reverse
|
||||
* way once the rate moves). Converting needs an FX rate. The supplier-invoice
|
||||
* form has one (Riksbanken-fetched or hand-typed); the customer-invoice
|
||||
* editor has none. When no rate is available we return null and the caller
|
||||
* shows nothing, rather than guessing a branch.
|
||||
*/
|
||||
|
||||
/** BFNAR 2016:10: the K2 accrual simplification ceiling, in SEK. */
|
||||
export const K2_ACCRUAL_THRESHOLD_SEK = 5000
|
||||
|
||||
export interface AccrualAmountInput {
|
||||
/** Net line amount, expressed in `currency`. */
|
||||
amount: number
|
||||
/** ISO currency code of `amount`. Missing/empty is treated as SEK. */
|
||||
currency?: string | null
|
||||
/** SEK per one unit of `currency`. Required to convert anything non-SEK. */
|
||||
exchangeRate?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The line amount in SEK, or null when it cannot be determined.
|
||||
*
|
||||
* Deliberately NOT `resolveSekAmount()` from `@/lib/bookkeeping/currency-utils`:
|
||||
* that helper falls back to returning the foreign amount untouched, which is
|
||||
* the silent wrong-unit branch this helper exists to avoid.
|
||||
*/
|
||||
export function resolveAccrualAmountSek({
|
||||
amount,
|
||||
currency,
|
||||
exchangeRate,
|
||||
}: AccrualAmountInput): number | null {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) return null
|
||||
|
||||
const code = (currency ?? '').trim().toUpperCase()
|
||||
if (code === '' || code === 'SEK') {
|
||||
return Math.round(amount * 100) / 100
|
||||
}
|
||||
|
||||
if (
|
||||
typeof exchangeRate !== 'number' ||
|
||||
!Number.isFinite(exchangeRate) ||
|
||||
exchangeRate <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Math.round(amount * exchangeRate * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the "below 5 000 kr need not be deferred (K2)" hint.
|
||||
*
|
||||
* False whenever the SEK value is unknown: no hint beats a hint measured in
|
||||
* the wrong currency.
|
||||
*/
|
||||
export function shouldShowK2AccrualHint(input: AccrualAmountInput): boolean {
|
||||
const amountSek = resolveAccrualAmountSek(input)
|
||||
if (amountSek === null) return false
|
||||
return amountSek > 0 && amountSek < K2_ACCRUAL_THRESHOLD_SEK
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
interface CustomerFormProps {
|
||||
@@ -105,6 +106,18 @@ export default function CustomerForm({
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Map the parsed body plus the status: on this path `result.error` is
|
||||
// the canonical envelope OBJECT (the route is withRouteContext), and
|
||||
// rendering it as a toast description would crash the React render.
|
||||
toast({
|
||||
title: t('vat_failed_title'),
|
||||
description: getErrorMessage(result, { statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setVatValidationResult({
|
||||
valid: result.valid,
|
||||
name: result.name,
|
||||
|
||||
@@ -124,9 +124,10 @@ export default function AttGoraSection({
|
||||
}
|
||||
} catch (err) {
|
||||
// Stale counts self-correct on the next page load: never block the
|
||||
// flow, but keep the failure observable (Sentry captures console.error)
|
||||
// so a systematically broken counts endpoint doesn't hide behind
|
||||
// silently frozen numbers.
|
||||
// flow. Note this is a browser console.error and nothing collects it:
|
||||
// the observability sink (lib/observability) is server-side and has no
|
||||
// client adapter, so a systematically broken counts endpoint currently
|
||||
// hides behind silently frozen numbers until someone reports it.
|
||||
console.error('[att-gora] worklist counts refetch failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,17 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to update status')
|
||||
// Map the parsed body plus the status, never `new Error(data.error)`:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
|
||||
@@ -73,8 +73,17 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to update status')
|
||||
// Map the parsed body plus the status, never `new Error(data.error)`:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
const body = await response.json().catch(() => null)
|
||||
toast({
|
||||
title: getUserErrorMessage(body, { statusCode: response.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
|
||||
@@ -555,7 +555,18 @@ function ConnectStep({
|
||||
const h = 700
|
||||
const left = window.screenX + (window.outerWidth - w) / 2
|
||||
const top = window.screenY + (window.outerHeight - h) / 2
|
||||
window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
|
||||
const popup = window.open(authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
|
||||
if (!popup) {
|
||||
// Popup blocked: with the return value discarded, a blocked
|
||||
// popup looked exactly like a successful one (nothing opens,
|
||||
// nothing is said, the user clicks again). Fall back to the
|
||||
// full-page flow instead. The callback already supports it:
|
||||
// with no window.opener it redirects to
|
||||
// /import?migration=connected&consentId=..., which
|
||||
// handleOAuthReturn consumes and resumes the wizard at the
|
||||
// preview step. Same treatment as SkatteverketConnectPanel.
|
||||
window.location.href = authUrl
|
||||
}
|
||||
}}
|
||||
>
|
||||
Logga in i {providerName}
|
||||
@@ -1929,8 +1940,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
|
||||
} else {
|
||||
// The pre-opened popup was blocked or closed; retrying here is a
|
||||
// long shot (the activation may be gone) but strictly better than
|
||||
// dropping the flow.
|
||||
window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
|
||||
// dropping the flow. If the retry is blocked too, take the same
|
||||
// full-page fallback as the first-connect button rather than leaving
|
||||
// "Återanslut" looking like it worked.
|
||||
const retry = window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
|
||||
if (!retry) {
|
||||
window.location.href = data.authUrl
|
||||
}
|
||||
}
|
||||
setAuthUrl(data.authUrl)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,6 +19,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { InvoiceExtractionResult, VatTreatment } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { summarizeUnderlagTotals } from './bulk-book-inbox-totals'
|
||||
|
||||
// Minimal shape the dialog needs from the workspace's inbox items.
|
||||
interface BulkBookInboxItem {
|
||||
@@ -84,6 +86,7 @@ function isBookable(it: BulkBookInboxItem): boolean {
|
||||
|
||||
export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('inbox_bulk_book')
|
||||
const [category, setCategory] = useState<string>('')
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment>('standard_25')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -109,10 +112,23 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
if (open) setVatTreatment('standard_25')
|
||||
}, [open])
|
||||
|
||||
const totalSek = useMemo(
|
||||
() => bookable.reduce((s, it) => s + (it.extracted_data?.totals?.total ?? 0), 0),
|
||||
[bookable],
|
||||
)
|
||||
// Underlag subtotals, split per currency. This used to be a single scalar
|
||||
// named `totalSek` that summed `totals.total` across the selection and was
|
||||
// rendered with formatCurrency()'s SEK default, so a mixed batch added 100
|
||||
// EUR to 100 SEK and stamped "kr" on the result: a figure the user approved
|
||||
// against that matched no belopp at all (BFL 5 kap 7 §). The split is honest
|
||||
// in both directions: a homogeneous EUR batch now reads in EUR too.
|
||||
//
|
||||
// Deliberately NOT a submit gate, unlike the mixed-currency dead end in
|
||||
// components/transactions/BulkBookDialog.tsx. That dialog builds ONE
|
||||
// samlingsverifikation, which must sit in a single redovisningsvaluta (BFL 4
|
||||
// kap 6 §). This route books one verifikat PER underlag off its matched bank
|
||||
// transaction's own settled amount (see lib/transactions/categorize-core.ts),
|
||||
// so a EUR invoice paid by a SEK bank line is booked correctly and a mixed
|
||||
// selection yields a set of individually correct verifikat. Blocking it would
|
||||
// refuse a legal everyday batch. See bulk-book-inbox-totals.ts.
|
||||
const underlagTotals = useMemo(() => summarizeUnderlagTotals(bookable), [bookable])
|
||||
const isMixedCurrency = underlagTotals.length > 1
|
||||
|
||||
const submit = async () => {
|
||||
if (!category || bookable.length === 0) return
|
||||
@@ -226,12 +242,29 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalSek > 0 && (
|
||||
{!isMixedCurrency && underlagTotals.length === 1 && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
Underlagens summa: {formatCurrency(totalSek)}
|
||||
{t('total_label', {
|
||||
amount: formatCurrency(underlagTotals[0]!.total, underlagTotals[0]!.currency),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isMixedCurrency && (
|
||||
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{t('mixed_currency_totals_label')}</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{underlagTotals.map(({ currency, total }) => (
|
||||
<li key={currency} className="flex items-center justify-between tabular-nums">
|
||||
<span className="font-mono">{currency}</span>
|
||||
<span className="text-foreground">{formatCurrency(total, currency)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2">{t('mixed_currency_note')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skippedNote && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hoppas över: {skippedNote}.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import AiFilledIndicator from '@/components/ui/ai-filled-indicator'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -37,6 +39,8 @@ import {
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
|
||||
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
@@ -87,6 +91,20 @@ interface InboxAddress {
|
||||
status: string
|
||||
}
|
||||
|
||||
// How far the underlag behind the selected row got.
|
||||
//
|
||||
// `none` is the only state that may claim "Inget underlag bifogat": it means the
|
||||
// row carries no document_id at all. A failed or hung metadata read is `error`,
|
||||
// never `none`: the document exists (the row points at it), we just could not
|
||||
// load it, and telling the user their underlag is missing invites a duplicate
|
||||
// upload or the conclusion that the receipt is gone (BFL 7 kap 2 § retention).
|
||||
type DocumentLoadState = 'none' | 'loading' | 'ready' | 'error'
|
||||
|
||||
// A signed-URL lookup is one indexed row plus a storage sign call: seconds at
|
||||
// worst, even on a cold start. 15s leaves several times that headroom while
|
||||
// still ending the spinner instead of leaving it turning forever.
|
||||
const DOCUMENT_FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
@@ -204,11 +222,16 @@ function WorkspaceSkeleton() {
|
||||
|
||||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const { openAgentSheet, identity } = useAgentSheet()
|
||||
|
||||
const [items, setItems] = useState<InboxItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
// The list read failed (non-2xx, unparseable body, or network). Kept apart
|
||||
// from "the list is empty": with no list at all we know nothing about the
|
||||
// inbox and must not render an authoritative "Inkorgen är tom".
|
||||
const [itemsLoadFailed, setItemsLoadFailed] = useState(false)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
// List filter + search (client-side over the already-fetched items list).
|
||||
// Defaults to 'todo': the active inbox (everything not yet booked), so
|
||||
@@ -232,7 +255,16 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const [selected, setSelected] = useState<InboxItem | null>(null)
|
||||
const [docUrl, setDocUrl] = useState<string | null>(null)
|
||||
const [docMime, setDocMime] = useState<string | null>(null)
|
||||
const [docState, setDocState] = useState<DocumentLoadState>('none')
|
||||
// Which selection the in-flight document read belongs to. The user can click
|
||||
// another row while it is running, and a late resolution must not paint its
|
||||
// outcome (a URL, or an error) onto the row that is now selected.
|
||||
const docRequestRef = useRef<string | null>(null)
|
||||
const [inboxAddress, setInboxAddress] = useState<InboxAddress | null>(null)
|
||||
// We asked for the inbox address and did not get an answer we can trust
|
||||
// (5xx, network, unparseable). Distinct from a 404, which honestly means no
|
||||
// address is provisioned yet.
|
||||
const [addressLoadFailed, setAddressLoadFailed] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
@@ -259,21 +291,27 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/items?limit=500')
|
||||
const json = await res.json()
|
||||
if (res.ok) {
|
||||
const serverItems: InboxItem[] = json.data?.items ?? []
|
||||
// Preserve optimistic upload placeholders that haven't resolved to a
|
||||
// server row yet. A refetch can now fire mid-upload (a realtime event
|
||||
// from an unrelated booking), and a wholesale replace would briefly
|
||||
// drop the in-flight placeholder. Placeholders carry a `temp-` id that
|
||||
// never collides with a real row, and uploadFile() removes its own
|
||||
// placeholder before its fetchItems(), so this never duplicates.
|
||||
setItems((prev) => {
|
||||
const pending = prev.filter((it) => it.isPlaceholder)
|
||||
return pending.length > 0 ? [...pending, ...serverItems] : serverItems
|
||||
})
|
||||
if (!res.ok) {
|
||||
// No list came back, so we cannot say anything about the inbox: the
|
||||
// list column renders the failure instead of "Inkorgen är tom".
|
||||
setItemsLoadFailed(true)
|
||||
return
|
||||
}
|
||||
const serverItems: InboxItem[] = json.data?.items ?? []
|
||||
// Preserve optimistic upload placeholders that haven't resolved to a
|
||||
// server row yet. A refetch can now fire mid-upload (a realtime event
|
||||
// from an unrelated booking), and a wholesale replace would briefly
|
||||
// drop the in-flight placeholder. Placeholders carry a `temp-` id that
|
||||
// never collides with a real row, and uploadFile() removes its own
|
||||
// placeholder before its fetchItems(), so this never duplicates.
|
||||
setItems((prev) => {
|
||||
const pending = prev.filter((it) => it.isPlaceholder)
|
||||
return pending.length > 0 ? [...pending, ...serverItems] : serverItems
|
||||
})
|
||||
setItemsLoadFailed(false)
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox] fetchItems failed:', err)
|
||||
setItemsLoadFailed(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -285,9 +323,20 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setInboxAddress(data)
|
||||
setAddressLoadFailed(false)
|
||||
return
|
||||
}
|
||||
// 404 is the honest "no inbox provisioned yet" answer, so the activate
|
||||
// button is the right thing to offer. Anything else (503 without an
|
||||
// inbound domain, 500, an HTML error page) leaves us not knowing whether
|
||||
// an address exists, and offering "Aktivera inkorgsadress" there is not
|
||||
// just wrong copy: handleRotateAddress skips its confirm dialog when
|
||||
// inboxAddress is null, so one click would silently retire a live address
|
||||
// that suppliers and forwarding rules already point at.
|
||||
setInboxAddress(null)
|
||||
setAddressLoadFailed(res.status !== 404)
|
||||
} catch {
|
||||
// 404 / 503 are expected when no address provisioned yet
|
||||
setAddressLoadFailed(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -366,8 +415,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
!!it.matched_transaction_id ||
|
||||
!!it.created_journal_entry_id
|
||||
)
|
||||
// The list read failed and left us with nothing: we do not know whether the
|
||||
// inbox is empty. A failed refetch that still has rows on screen is not this.
|
||||
const itemsUnknown = itemsLoadFailed && !hasAnyItem
|
||||
// Never coach "ladda upp ditt första underlag" off a list we could not read:
|
||||
// that step may well be done already.
|
||||
const showOnboarding =
|
||||
!onboardingDismissed && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||||
!onboardingDismissed && !itemsUnknown && !(hasInboxAddress && hasAnyItem && hasResolvedItem)
|
||||
|
||||
// ── List filter + search (client-side over the fetched list) ─
|
||||
|
||||
@@ -427,11 +481,58 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────
|
||||
|
||||
// Resolve the signed URL for a row's underlag. Every exit sets docState, so
|
||||
// the preview pane always says which of the four situations it is in: no
|
||||
// document, still loading, ready, or "we could not load it". The pane must
|
||||
// never fall back to "Inget underlag bifogat" for a row that has a
|
||||
// document_id, which is what the old silent catch produced.
|
||||
const loadDocument = useCallback(async (itemId: string, documentId: string | null) => {
|
||||
docRequestRef.current = itemId
|
||||
setDocUrl(null)
|
||||
setDocMime(null)
|
||||
if (!documentId) {
|
||||
setDocState('none')
|
||||
return
|
||||
}
|
||||
setDocState('loading')
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`/api/documents/${documentId}`,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs: DOCUMENT_FETCH_TIMEOUT_MS, description: `document ${documentId}` },
|
||||
)
|
||||
if (docRequestRef.current !== itemId) return
|
||||
if (!res.ok) {
|
||||
setDocState('error')
|
||||
return
|
||||
}
|
||||
const { data } = await res.json()
|
||||
if (docRequestRef.current !== itemId) return
|
||||
const url: string | null = data?.download_url ?? null
|
||||
if (!url) {
|
||||
// The document row exists but no signed URL came back: still a load
|
||||
// failure, not an absent underlag.
|
||||
setDocState('error')
|
||||
return
|
||||
}
|
||||
setDocUrl(url)
|
||||
setDocMime(data?.mime_type ?? null)
|
||||
setDocState('ready')
|
||||
} catch {
|
||||
// Timeout, offline, or an unparseable body. The document itself is
|
||||
// untouched, so the retry in the preview pane is the whole recovery.
|
||||
if (docRequestRef.current !== itemId) return
|
||||
setDocState('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback(async (id: string) => {
|
||||
setSelectedId(id)
|
||||
setSelected(null)
|
||||
setDocUrl(null)
|
||||
setDocMime(null)
|
||||
setDocState('none')
|
||||
docRequestRef.current = id
|
||||
// Intentionally no auto-scroll: in the vertical-stack layout (below xl)
|
||||
// scrolling the preview into view pushes the list off-screen, and the
|
||||
// user has no obvious way back to pick another item. The row-highlight
|
||||
@@ -443,19 +544,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
if (!res.ok) throw new Error(json.error ?? 'Kunde inte hämta posten')
|
||||
const item = json.data as InboxItem
|
||||
setSelected(item)
|
||||
|
||||
if (item.document_id) {
|
||||
try {
|
||||
const docRes = await fetch(`/api/documents/${item.document_id}`)
|
||||
if (docRes.ok) {
|
||||
const { data } = await docRes.json()
|
||||
setDocUrl(data.download_url ?? null)
|
||||
setDocMime(data.mime_type ?? null)
|
||||
}
|
||||
} catch {
|
||||
// Preview is optional
|
||||
}
|
||||
}
|
||||
await loadDocument(id, item.document_id)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda dokumentet',
|
||||
@@ -463,7 +552,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}, [toast])
|
||||
}, [toast, loadDocument])
|
||||
|
||||
// ── Upload ─────────────────────────────────────────────────
|
||||
|
||||
@@ -678,14 +767,17 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
|
||||
// ── Inbox address ──────────────────────────────────────────
|
||||
|
||||
const handleCopyAddress = useCallback(() => {
|
||||
if (!inboxAddress) return
|
||||
navigator.clipboard.writeText(inboxAddress.address).catch(() => {})
|
||||
toast({ title: 'Adress kopierad' })
|
||||
}, [inboxAddress, toast])
|
||||
|
||||
const handleRotateAddress = useCallback(async () => {
|
||||
if (inboxAddress && !confirm('Skapa en ny inkorgsadress? Den gamla slutar att fungera.')) return
|
||||
// Confirm whenever an address may already exist: either we hold one, or the
|
||||
// read failed and we cannot rule one out. Rotating retires the old address,
|
||||
// and suppliers plus forwarding rules already point at it, so the one case
|
||||
// that must never skip this dialog is the one where we are unsure.
|
||||
if (
|
||||
(inboxAddress || addressLoadFailed) &&
|
||||
!confirm('Skapa en ny inkorgsadress? Den gamla slutar att fungera.')
|
||||
) {
|
||||
return
|
||||
}
|
||||
setIsRotating(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox/rotate', {
|
||||
@@ -694,6 +786,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error ?? 'Rotation misslyckades')
|
||||
setInboxAddress(json.data)
|
||||
setAddressLoadFailed(false)
|
||||
toast({ title: 'Ny adress skapad', description: json.data.address })
|
||||
} catch (err) {
|
||||
toast({
|
||||
@@ -704,7 +797,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
} finally {
|
||||
setIsRotating(false)
|
||||
}
|
||||
}, [toast, inboxAddress])
|
||||
}, [toast, inboxAddress, addressLoadFailed])
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────
|
||||
|
||||
@@ -727,33 +820,21 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
<Inbox className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h1 className="font-medium text-sm shrink-0">Dokumentinkorg</h1>
|
||||
{inboxAddress ? (
|
||||
<>
|
||||
<span className="text-muted-foreground text-xs shrink-0">·</span>
|
||||
<code className="font-mono text-xs text-muted-foreground truncate min-w-0">
|
||||
{inboxAddress.address}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCopyAddress}
|
||||
title="Kopiera adress"
|
||||
<InboxAddressBar
|
||||
address={inboxAddress.address}
|
||||
onRotate={handleRotateAddress}
|
||||
isRotating={isRotating}
|
||||
/>
|
||||
) : addressLoadFailed ? (
|
||||
// We do not know whether an address exists, so we offer a retry
|
||||
// rather than an activate button that would rotate a live address.
|
||||
<div role="status" aria-live="polite" className="min-w-0">
|
||||
<AttnLine
|
||||
action={{ label: t('retry'), onClick: () => { void fetchInboxAddress() } }}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleRotateAddress}
|
||||
disabled={isRotating}
|
||||
title="Rotera till ny adress"
|
||||
>
|
||||
{isRotating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
{t('address_load_failed')}
|
||||
</AttnLine>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -911,7 +992,22 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!hasAnyItem ? (
|
||||
{itemsUnknown ? (
|
||||
// The list read failed. "Inkorgen är tom" would be a claim we
|
||||
// cannot back: a mailed-in invoice may be sitting there unread.
|
||||
<div className="p-6 text-center text-sm text-muted-foreground space-y-2">
|
||||
<AlertTriangle className="h-5 w-5 mx-auto text-attn" />
|
||||
<p>{t('items_load_failed')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => { void fetchItems() }}
|
||||
>
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : !hasAnyItem ? (
|
||||
// On desktop the preview pane is always visible alongside this
|
||||
// column, so showing the onboarding card here would duplicate it.
|
||||
// Below xl the panes stack into one feed, so the sibling preview
|
||||
@@ -977,7 +1073,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
)}
|
||||
>
|
||||
{selected ? (
|
||||
<DocumentPreview docUrl={docUrl} docMime={docMime} isProcessing={!!selected.isPlaceholder} />
|
||||
<DocumentPreview
|
||||
docUrl={docUrl}
|
||||
docMime={docMime}
|
||||
isProcessing={!!selected.isPlaceholder}
|
||||
loadState={docState}
|
||||
onRetry={() => { void loadDocument(selected.id, selected.document_id) }}
|
||||
/>
|
||||
) : showOnboarding ? (
|
||||
<div className="h-full flex items-center justify-center px-4 py-6">
|
||||
<OnboardingCard
|
||||
@@ -993,7 +1095,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
) : (
|
||||
<EmptyPreview
|
||||
onUploadClick={() => fileInputRef.current?.click()}
|
||||
onActivateInbox={inboxAddress ? null : handleRotateAddress}
|
||||
// Only offer activation when we know there is nothing to retire:
|
||||
// a failed address read is not a "you have no address yet".
|
||||
onActivateInbox={inboxAddress || addressLoadFailed ? null : handleRotateAddress}
|
||||
isActivating={isRotating}
|
||||
/>
|
||||
)}
|
||||
@@ -1131,6 +1235,94 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}
|
||||
|
||||
|
||||
// ── Inbox address bar ────────────────────────────────────────
|
||||
// The address plus its copy and rotate controls. Owns the copy state so the
|
||||
// header can report an honest outcome: a clipboard write rejects for ordinary
|
||||
// reasons (insecure context, a blocking Permissions-Policy, a document that
|
||||
// lost focus) and the previous handler swallowed that and said "Adress
|
||||
// kopierad" anyway. The user then waits for supplier invoices at an address
|
||||
// they never captured, which is unrecoverable in the sense that matters: no
|
||||
// invoice ever arrives and nothing explains why.
|
||||
//
|
||||
// Treatment mirrors CopyBlock in components/settings/ApiKeysPanel.tsx: icon
|
||||
// swap for the state, one ochre AttnLine on failure, live region always
|
||||
// mounted. No toast on any path, so nothing here can be evicted by (or evict)
|
||||
// another toast under TOAST_LIMIT = 1.
|
||||
|
||||
function InboxAddressBar({
|
||||
address,
|
||||
onRotate,
|
||||
isRotating,
|
||||
}: {
|
||||
address: string
|
||||
onRotate: () => void
|
||||
isRotating: boolean
|
||||
}) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const [copyState, setCopyState] = useState<AddressCopyState>('idle')
|
||||
|
||||
async function handleCopy() {
|
||||
// The clipboard write is the first await, so the click's user activation
|
||||
// still holds when it runs.
|
||||
const next = await copyInboxAddress(address)
|
||||
setCopyState(next)
|
||||
if (next === 'copied') setTimeout(() => setCopyState('idle'), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground text-xs shrink-0">·</span>
|
||||
<code
|
||||
className={cn(
|
||||
'select-all font-mono text-xs text-muted-foreground min-w-0',
|
||||
// With no clipboard, reading the address off the screen is the only
|
||||
// way to get it: show it whole instead of ellipsised.
|
||||
copyState === 'failed' ? 'break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{address}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('copy_address')}
|
||||
title={t('copy_address')}
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-3.5 w-3.5 text-success" />
|
||||
) : copyState === 'failed' ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-attn" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={onRotate}
|
||||
disabled={isRotating}
|
||||
title="Rotera till ny adress"
|
||||
>
|
||||
{isRotating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Always mounted so the sentence is announced when it appears, not
|
||||
merely inserted. */}
|
||||
<div role="status" aria-live="polite">
|
||||
{copyState === 'failed' && (
|
||||
<AttnLine className="mt-1">{t('copy_address_failed')}</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List row ─────────────────────────────────────────────────
|
||||
|
||||
function InboxRow({
|
||||
@@ -1244,11 +1436,19 @@ export function DocumentPreview({
|
||||
docUrl,
|
||||
docMime,
|
||||
isProcessing = false,
|
||||
loadState,
|
||||
onRetry,
|
||||
}: {
|
||||
docUrl: string | null
|
||||
docMime: string | null
|
||||
isProcessing?: boolean
|
||||
/** Omitted by callers that only ever hold a resolved URL. */
|
||||
loadState?: DocumentLoadState
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const state: DocumentLoadState = loadState ?? (docUrl ? 'ready' : 'none')
|
||||
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
@@ -1257,6 +1457,29 @@ export function DocumentPreview({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span>{t('document_loading')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (state === 'error' || (state === 'ready' && !docUrl)) {
|
||||
// The row points at a stored document: say that it could not be shown, not
|
||||
// that there is nothing attached.
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-5 w-5 text-attn" />
|
||||
<span>{t('document_load_failed')}</span>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
|
||||
{t('retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!docUrl) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Coverage for the per-currency totalling behind the "Bokför valda" underlag
|
||||
* dialog. The dialog itself is never rendered here (Vitest runs in the `node`
|
||||
* environment, no component tests in this repo), which is exactly why the rule
|
||||
* was extracted into a pure module.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
normalizeUnderlagCurrency,
|
||||
summarizeUnderlagTotals,
|
||||
type UnderlagTotalsItem,
|
||||
} from '../bulk-book-inbox-totals'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
/** Minimal extraction shaped like the real one; only the read fields matter. */
|
||||
function item(total: number | null, currency?: string | null): UnderlagTotalsItem {
|
||||
return {
|
||||
extracted_data: {
|
||||
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
// `currency` is typed non-nullable on InvoiceExtractionResult, but rows
|
||||
// written before the field existed really do arrive without it, so the
|
||||
// cast reproduces the shape the dialog actually receives at runtime.
|
||||
invoice: {
|
||||
invoiceNumber: null,
|
||||
invoiceDate: null,
|
||||
dueDate: null,
|
||||
paymentReference: null,
|
||||
currency: currency as string,
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total },
|
||||
vatBreakdown: [],
|
||||
confidence: 1,
|
||||
} as InvoiceExtractionResult,
|
||||
}
|
||||
}
|
||||
|
||||
describe('normalizeUnderlagCurrency', () => {
|
||||
it('passes through a well-formed code, uppercased', () => {
|
||||
expect(normalizeUnderlagCurrency('EUR')).toBe('EUR')
|
||||
expect(normalizeUnderlagCurrency('eur')).toBe('EUR')
|
||||
expect(normalizeUnderlagCurrency(' usd ')).toBe('USD')
|
||||
})
|
||||
|
||||
it('treats a missing currency as SEK (legacy extractions predate the field)', () => {
|
||||
expect(normalizeUnderlagCurrency(null)).toBe('SEK')
|
||||
expect(normalizeUnderlagCurrency(undefined)).toBe('SEK')
|
||||
expect(normalizeUnderlagCurrency('')).toBe('SEK')
|
||||
expect(normalizeUnderlagCurrency(' ')).toBe('SEK')
|
||||
})
|
||||
|
||||
it('falls back to SEK on a malformed code rather than letting Intl throw', () => {
|
||||
// formatCurrency() -> Intl.NumberFormat raises RangeError on these, and the
|
||||
// value comes from AI extraction of a PDF, so it is not trustworthy input.
|
||||
expect(normalizeUnderlagCurrency('Euro')).toBe('SEK')
|
||||
expect(normalizeUnderlagCurrency('kr')).toBe('SEK')
|
||||
expect(normalizeUnderlagCurrency('12')).toBe('SEK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeUnderlagTotals: single-currency selections still work', () => {
|
||||
it('sums a plain SEK selection into one entry', () => {
|
||||
const totals = summarizeUnderlagTotals([
|
||||
item(1000, 'SEK'),
|
||||
item(250.5, 'SEK'),
|
||||
item(99.25, 'SEK'),
|
||||
])
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 1349.75 }])
|
||||
})
|
||||
|
||||
it('keeps a homogeneous foreign selection in its own currency, not SEK', () => {
|
||||
// The old code rendered this through formatCurrency() with the SEK
|
||||
// default, so 3 000 EUR read as "3 000 kr".
|
||||
const totals = summarizeUnderlagTotals([item(1000, 'EUR'), item(2000, 'EUR')])
|
||||
expect(totals).toEqual([{ currency: 'EUR', total: 3000 }])
|
||||
})
|
||||
|
||||
it('rounds to öre with Math.round(x * 100) / 100, never toFixed', () => {
|
||||
const totals = summarizeUnderlagTotals([item(0.1, 'SEK'), item(0.2, 'SEK')])
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 0.3 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeUnderlagTotals: a legacy NULL + SEK batch is NOT a currency mix', () => {
|
||||
it('groups a missing currency with SEK instead of reporting two currencies', () => {
|
||||
const totals = summarizeUnderlagTotals([
|
||||
item(500, null),
|
||||
item(300, 'SEK'),
|
||||
item(200, undefined),
|
||||
item(100, ''),
|
||||
])
|
||||
expect(totals).toHaveLength(1)
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 1100 }])
|
||||
})
|
||||
|
||||
it('does not split lowercase sek away from SEK', () => {
|
||||
const totals = summarizeUnderlagTotals([item(10, 'sek'), item(20, 'SEK')])
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 30 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeUnderlagTotals: mixed selections report per-currency subtotals', () => {
|
||||
it('never collapses two currencies into one scalar', () => {
|
||||
const totals = summarizeUnderlagTotals([
|
||||
item(100, 'SEK'),
|
||||
item(100, 'EUR'),
|
||||
item(50, 'SEK'),
|
||||
])
|
||||
expect(totals).toEqual([
|
||||
{ currency: 'EUR', total: 100 },
|
||||
{ currency: 'SEK', total: 150 },
|
||||
])
|
||||
// The old scalar would have been 250, a number denominated in nothing.
|
||||
expect(totals.reduce((s, t) => s + t.total, 0)).not.toBe(totals[0]!.total)
|
||||
})
|
||||
|
||||
it('sorts by currency code so the list is stable across renders', () => {
|
||||
const totals = summarizeUnderlagTotals([item(1, 'USD'), item(1, 'EUR'), item(1, 'SEK')])
|
||||
expect(totals.map((t) => t.currency)).toEqual(['EUR', 'SEK', 'USD'])
|
||||
})
|
||||
|
||||
it('separates a legacy NULL (SEK) from a genuine foreign underlag', () => {
|
||||
const totals = summarizeUnderlagTotals([item(400, null), item(600, 'EUR')])
|
||||
expect(totals).toEqual([
|
||||
{ currency: 'EUR', total: 600 },
|
||||
{ currency: 'SEK', total: 400 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeUnderlagTotals: items without an extracted amount', () => {
|
||||
it('returns an empty list when nothing was extracted', () => {
|
||||
expect(summarizeUnderlagTotals([])).toEqual([])
|
||||
expect(summarizeUnderlagTotals([{ extracted_data: null }])).toEqual([])
|
||||
expect(summarizeUnderlagTotals([item(null, 'SEK')])).toEqual([])
|
||||
})
|
||||
|
||||
it('skips a null total instead of counting it as zero in that currency', () => {
|
||||
const totals = summarizeUnderlagTotals([item(null, 'EUR'), item(100, 'SEK')])
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 100 }])
|
||||
})
|
||||
|
||||
it('ignores a non-finite total rather than poisoning the subtotal with NaN', () => {
|
||||
const totals = summarizeUnderlagTotals([item(Number.NaN, 'SEK'), item(100, 'SEK')])
|
||||
expect(totals).toEqual([{ currency: 'SEK', total: 100 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { copyInboxAddress } from '@/components/extensions/general/inbox-address-copy'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('copyInboxAddress', () => {
|
||||
it('reports copied and forwards the exact address on success', async () => {
|
||||
const writeText = vi.fn(async () => undefined)
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
|
||||
await expect(copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')).resolves.toBe('copied')
|
||||
expect(writeText).toHaveBeenCalledWith('faktura-a1b2c3@inbox.gnubok.se')
|
||||
})
|
||||
|
||||
it('reports failed when the write is rejected, never copied', async () => {
|
||||
// The regression guard. The old handler swallowed this rejection in an empty
|
||||
// catch and then showed "Adress kopierad" anyway, so the user walked away
|
||||
// believing they had the inbox address and waited for invoices at an address
|
||||
// they never captured.
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: {
|
||||
writeText: vi.fn(async () => {
|
||||
throw new DOMException('Write permission denied.', 'NotAllowedError')
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const state = await copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')
|
||||
expect(state).toBe('failed')
|
||||
expect(state).not.toBe('copied')
|
||||
})
|
||||
|
||||
it('reports failed on a non-Error rejection', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
clipboard: { writeText: vi.fn(() => Promise.reject('nope')) },
|
||||
})
|
||||
|
||||
await expect(copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')).resolves.toBe('failed')
|
||||
})
|
||||
|
||||
it('reports failed when the Clipboard API is missing (insecure context)', async () => {
|
||||
// No API means no programmatic fallback either: the header must keep the
|
||||
// address visible and selectable instead of claiming a copy.
|
||||
vi.stubGlobal('navigator', {})
|
||||
|
||||
const state = await copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')
|
||||
expect(state).toBe('failed')
|
||||
expect(state).not.toBe('copied')
|
||||
})
|
||||
|
||||
it('reports failed when clipboard exists but writeText does not', async () => {
|
||||
vi.stubGlobal('navigator', { clipboard: { read: vi.fn() } })
|
||||
|
||||
await expect(copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')).resolves.toBe('failed')
|
||||
})
|
||||
|
||||
it('does not throw when there is no navigator at all', async () => {
|
||||
vi.stubGlobal('navigator', undefined)
|
||||
|
||||
await expect(copyInboxAddress('faktura-a1b2c3@inbox.gnubok.se')).resolves.toBe('failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Pure per-currency totalling for the "Bokför valda" underlag dialog.
|
||||
*
|
||||
* Lives outside BulkBookInboxDialog.tsx (a `'use client'` component) so the
|
||||
* rule below can be unit tested: this repo runs Vitest in the `node`
|
||||
* environment and never renders components, so logic embedded in JSX is
|
||||
* unverifiable by construction.
|
||||
*
|
||||
* # Why this exists
|
||||
*
|
||||
* The dialog used to reduce the whole selection to ONE scalar
|
||||
* (`bookable.reduce((s, it) => s + (it.extracted_data?.totals?.total ?? 0), 0)`)
|
||||
* and render it through `formatCurrency()`, which defaults to SEK. Both halves
|
||||
* of that are wrong the moment the underlag are not all SEK:
|
||||
*
|
||||
* 1. It adds 100 EUR to 100 SEK as if they were one unit, so the figure the
|
||||
* user approves against corresponds to no real belopp (BFL 5 kap 7 §
|
||||
* requires a verifikation to state the belopp of the affärshändelse; a
|
||||
* cross-currency sum is not one).
|
||||
* 2. Even for a homogeneous non-SEK selection it stamped "kr" on a total
|
||||
* denominated in something else, so three 1 000 EUR invoices read as
|
||||
* "3 000 kr".
|
||||
*
|
||||
* # Why this is a display fix and NOT a submit block
|
||||
*
|
||||
* Unlike `components/transactions/BulkBookDialog.tsx`, this dialog does not
|
||||
* build a samlingsverifikation. `POST /items/bulk-book` runs
|
||||
* `bulkBookMatchedInboxItems`, which loops the selection and calls
|
||||
* `categorizeMatchedTransaction` per item: ONE verifikat per underlag, and the
|
||||
* booked belopp is read off that item's matched bank transaction, never off
|
||||
* `extracted_data.totals.total` (see the header of
|
||||
* `lib/transactions/categorize-core.ts`: "Booking is always in SEK off the bank
|
||||
* transaction's own amount"). A EUR invoice settled by the bank in SEK is
|
||||
* therefore booked correctly and legally, and a selection mixing SEK and EUR
|
||||
* underlag produces a set of individually correct SEK verifikat.
|
||||
*
|
||||
* So there is no BFL 4 kap 6 § redovisningsvaluta conflict to refuse here, and
|
||||
* blocking the submit would break a legitimate everyday flow (a batch of
|
||||
* receipts where some suppliers invoice in EUR). The honest fix is to stop
|
||||
* presenting the misleading scalar and show what is actually true: a subtotal
|
||||
* per currency, plus a note that the booking follows the bank amount.
|
||||
*/
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
/** The subset of an inbox item this module needs. */
|
||||
export interface UnderlagTotalsItem {
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
}
|
||||
|
||||
export interface UnderlagCurrencyTotal {
|
||||
/** ISO 4217 code, uppercased. */
|
||||
currency: string
|
||||
total: number
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an underlag's currency to a code that is safe to both group by and
|
||||
* hand to `Intl.NumberFormat`.
|
||||
*
|
||||
* A missing/blank currency normalizes to 'SEK'. This matters for correctness,
|
||||
* not just tidiness: `extracted_data.invoice.currency` is produced by document
|
||||
* extraction and older rows predate the field, so an un-normalized
|
||||
* `new Set(items.map(i => i.extracted_data?.invoice?.currency))` would split a
|
||||
* legacy `null`/`undefined` underlag away from its SEK siblings and report a
|
||||
* currency mix that does not exist. `pickCurrency()` in
|
||||
* InvoiceInboxWorkspace.tsx and `emptyExtraction()` both already read a missing
|
||||
* currency as SEK, so this keeps the dialog consistent with the list behind it.
|
||||
*
|
||||
* Anything that is not a plausible 3-letter code also falls back to 'SEK':
|
||||
* `formatCurrency` throws a RangeError on a malformed code, and this value
|
||||
* comes from AI extraction of a PDF, so it cannot be trusted to be well formed.
|
||||
* Crashing the approval dialog on a junk code would be strictly worse than
|
||||
* grouping it with the default.
|
||||
*/
|
||||
export function normalizeUnderlagCurrency(currency: string | null | undefined): string {
|
||||
const code = currency?.trim().toUpperCase()
|
||||
return code && /^[A-Z]{3}$/.test(code) ? code : 'SEK'
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtotal the selection per currency, sorted by currency code.
|
||||
*
|
||||
* Items whose extraction produced no total contribute nothing (they are not
|
||||
* counted as a 0 in some currency), so a selection with no extracted amounts
|
||||
* yields an empty array and the caller renders nothing: the same "no figure to
|
||||
* show" outcome the old `totalSek > 0` gate produced.
|
||||
*
|
||||
* A result of length > 1 means the selection genuinely spans currencies and
|
||||
* the caller must not render a combined scalar.
|
||||
*/
|
||||
export function summarizeUnderlagTotals(items: UnderlagTotalsItem[]): UnderlagCurrencyTotal[] {
|
||||
const byCurrency = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const total = item.extracted_data?.totals?.total
|
||||
if (total == null || !Number.isFinite(total)) continue
|
||||
const currency = normalizeUnderlagCurrency(item.extracted_data?.invoice?.currency)
|
||||
byCurrency.set(currency, round2((byCurrency.get(currency) ?? 0) + total))
|
||||
}
|
||||
return Array.from(byCurrency.entries())
|
||||
.map(([currency, total]) => ({ currency, total }))
|
||||
.sort((a, b) => a.currency.localeCompare(b.currency))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
|
||||
|
||||
/**
|
||||
* Copy state for the inbox address in the workspace header.
|
||||
*
|
||||
* `idle` claims nothing, `copied` is only reachable after the clipboard write
|
||||
* resolved, and `failed` means the address is NOT on the clipboard.
|
||||
*/
|
||||
export type AddressCopyState = 'idle' | 'copied' | 'failed'
|
||||
|
||||
/**
|
||||
* Copy the document-inbox address and report which state the header must show.
|
||||
*
|
||||
* Why this exists as its own function: the previous handler fired
|
||||
* `navigator.clipboard.writeText(...).catch(() => {})` and then showed
|
||||
* "Adress kopierad" unconditionally, so an insecure context, a blocking
|
||||
* Permissions-Policy or a document that had lost focus produced a success
|
||||
* message with an empty clipboard. The user then waits for supplier invoices at
|
||||
* an address they never captured, and nothing in the UI ever contradicts that.
|
||||
*
|
||||
* `unavailable` (no Clipboard API at all) and `failed` (the write was rejected)
|
||||
* collapse into one UI state on purpose: from the user's side both mean the
|
||||
* address is not on the clipboard and manual selection is the only way to get
|
||||
* it. The distinction only matters for logging, which this surface does not do.
|
||||
*
|
||||
* Call this as the first `await` in the click handler: a clipboard write issued
|
||||
* after another await runs outside the click's transient user activation and is
|
||||
* rejected on that ground alone.
|
||||
*/
|
||||
export async function copyInboxAddress(
|
||||
address: string,
|
||||
): Promise<Exclude<AddressCopyState, 'idle'>> {
|
||||
const result = await copyToClipboard(address)
|
||||
return result === 'copied' ? 'copied' : 'failed'
|
||||
}
|
||||
@@ -22,7 +22,14 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { getVatRules } from '@/lib/invoices/vat-rules'
|
||||
import {
|
||||
resolveLineVatRates,
|
||||
planCustomerSwitchVatSnap,
|
||||
hasSwedishVatToForeignBusiness,
|
||||
FALLBACK_VAT_RATE,
|
||||
} from '@/components/invoices/line-vat-rates'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { sortArticles } from '@/lib/articles/sort'
|
||||
import { getAmountToPay } from '@/lib/invoices/rounding'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
@@ -333,6 +340,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
// re-derive due_date / forced VAT rates from it: those came from the saved
|
||||
// draft. Starts true for create (always derive), false for edit (skip once).
|
||||
const didInitialCustomerSync = useRef(!isEditMode)
|
||||
// The DEFAULT VAT rate of the customer currently selected. A customer switch
|
||||
// compares against it to tell an inherited line rate (follows the new
|
||||
// customer) from a deliberate one (left alone). Starts at the rate an empty
|
||||
// form's first line carries, before any customer is picked.
|
||||
const previousDefaultRateRef = useRef<number>(FALLBACK_VAT_RATE)
|
||||
// Edit and copy pre-fill the lines from an existing invoice, and the customer
|
||||
// that resolves first IS that invoice's customer: its rates are already
|
||||
// correct, so the first resolution must only RECORD the baseline, never snap.
|
||||
// A fresh form has no such baseline, so there the first pick does snap.
|
||||
const didSeedVatSnapBaseline = useRef(!(isEditMode || isCopyMode))
|
||||
|
||||
// Edit mode: the claim card's property fields are restored from the first
|
||||
// rot line (they're stamped onto every rot line server-side at save time).
|
||||
@@ -536,10 +553,14 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
setValue(`items.${index}.description`, a.name, { shouldValidate: true, shouldDirty: true })
|
||||
if (a.unit) setValue(`items.${index}.unit`, a.unit, { shouldDirty: true })
|
||||
setValue(`items.${index}.unit_price`, Number(a.price_excl_vat) || 0, { shouldValidate: true, shouldDirty: true })
|
||||
// Only adopt the article's VAT rate when it's allowed for this customer
|
||||
// (and the rate isn't locked, e.g. reverse charge / export). Otherwise keep
|
||||
// the line's current rate so the API's per-customer VAT rule isn't violated.
|
||||
if (!isRateLocked && availableRates.some((r) => r.rate === a.vat_rate)) {
|
||||
// Only adopt the article's VAT rate when it belongs to the customer's
|
||||
// DEFAULT set, never to the wider permitted set. An article's stored rate is
|
||||
// its domestic rate; nothing on it says the supply is one of the ML 6 kap.
|
||||
// ones taxed where performed. Adopting 25% because the article says 25%
|
||||
// would silently put Swedish VAT on a reverse-charge invoice, so a foreign
|
||||
// business customer (single locked 0% default) keeps the line's rate and the
|
||||
// user picks 12%/6% explicitly when it really is a hotel night or a ticket.
|
||||
if (!vatRatePlan.hasSingleDefault && vatRatePlan.defaultRates.some((r) => r.rate === a.vat_rate)) {
|
||||
setValue(`items.${index}.vat_rate`, a.vat_rate, { shouldValidate: true, shouldDirty: true })
|
||||
}
|
||||
// The account override rides along regardless of rate; the engine ignores it
|
||||
@@ -699,11 +720,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
const customer = customers.find((c) => c.id === watchCustomerId)
|
||||
setSelectedCustomer(customer || null)
|
||||
|
||||
// Skip the derived side-effects (due_date, forced VAT rate) the first time
|
||||
// Skip the derived side-effects (due_date, VAT rate snap) the first time
|
||||
// we resolve a pre-filled customer in edit mode: those values came from
|
||||
// the saved draft and must not be overwritten. Applied normally on every
|
||||
// subsequent (user-initiated) customer change, and always in create mode.
|
||||
if (customer) {
|
||||
const nextDefaultRate = resolveLineVatRates(customer).defaultRate
|
||||
if (didInitialCustomerSync.current) {
|
||||
// Update due date based on customer payment terms
|
||||
if (customer.default_payment_terms) {
|
||||
@@ -713,16 +735,24 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
)
|
||||
}
|
||||
|
||||
// When the customer forces a single rate (reverse charge/export),
|
||||
// update all lines so the picker can't leave stale 25% values behind.
|
||||
const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
if (rates.length === 1) {
|
||||
const forcedRate = rates[0].rate
|
||||
watchItems.forEach((_, i) => {
|
||||
setValue(`items.${i}.vat_rate`, forcedRate)
|
||||
})
|
||||
// Move only the lines still sitting on the OLD customer's default
|
||||
// rate onto the new one: the switch must not leave a stale 25% on a
|
||||
// reverse-charge invoice, nor a stale 0% on a domestic one. A line
|
||||
// the user moved off that default stays put: 12% on a Stockholm
|
||||
// hotel night sold to a German company is lawful (taxed where
|
||||
// performed, ML 6 kap.) and snapping it to 0% would destroy it.
|
||||
if (didSeedVatSnapBaseline.current) {
|
||||
for (const snap of planCustomerSwitchVatSnap({
|
||||
items: watchItems ?? [],
|
||||
previousDefaultRate: previousDefaultRateRef.current,
|
||||
nextDefaultRate,
|
||||
})) {
|
||||
setValue(`items.${snap.index}.vat_rate`, snap.rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
previousDefaultRateRef.current = nextDefaultRate
|
||||
didSeedVatSnapBaseline.current = true
|
||||
didInitialCustomerSync.current = true
|
||||
}
|
||||
}
|
||||
@@ -786,10 +816,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
|
||||
: null
|
||||
|
||||
const availableRates = selectedCustomer
|
||||
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
|
||||
: []
|
||||
const isRateLocked = availableRates.length === 1
|
||||
// Rendered options and the default are deliberately two different sets:
|
||||
// `options` is what may LAWFULLY appear on a line (getPermittedVatRates),
|
||||
// `defaultRates` / `defaultRate` is what the form OFFERS by itself
|
||||
// (getAvailableVatRates). See components/invoices/line-vat-rates.ts.
|
||||
const vatRatePlan = resolveLineVatRates(selectedCustomer)
|
||||
// One ochre sentence, and only once a Swedish rate is actually selected on an
|
||||
// invoice to a foreign business: 0% is the rule, a non-zero rate is lawful
|
||||
// only for the ML 6 kap. supplies taxed where they are performed.
|
||||
const showTaxedWherePerformedHint =
|
||||
vatRegistered && hasSwedishVatToForeignBusiness({ plan: vatRatePlan, items: watchItems ?? [] })
|
||||
// A non-momsregistrerad company never charges VAT: hide the Moms column and
|
||||
// book every line momsfritt. `vatRegistered` is the single switch the whole
|
||||
// form keys off: no rate picker, no warning, no VAT in the totals/preview.
|
||||
@@ -1817,13 +1853,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<Select
|
||||
value={String(field.value ?? 25)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
disabled={isRateLocked}
|
||||
disabled={vatRatePlan.isPickerLocked}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRates.map((opt) => (
|
||||
{/* The lawful set, not the default one: a
|
||||
foreign business customer gets 0% first
|
||||
(and preselected) plus 25/12/6 for the
|
||||
supplies taxed where they are performed. */}
|
||||
{vatRatePlan.options.map((opt) => (
|
||||
<SelectItem key={opt.rate} value={String(opt.rate)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
@@ -1928,6 +1968,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
<AccrualPeriodControl
|
||||
direction="revenue"
|
||||
amount={lineTotal}
|
||||
/* The customer-invoice editor carries no FX rate
|
||||
(the form has no exchange_rate field), so the
|
||||
currency alone is passed: it keeps the preview
|
||||
honest and suppresses the SEK-only K2 hint on
|
||||
foreign-currency lines. */
|
||||
currency={watchCurrency}
|
||||
idPrefix={`accrual-invoice-${index}`}
|
||||
value={{
|
||||
start: watchItems[index]?.accrual_period_start ?? '',
|
||||
@@ -2029,7 +2075,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
vat_rate: vatRegistered ? (availableRates[0]?.rate ?? 25) : 0,
|
||||
// The DEFAULT, never the widest permitted rate: 0% for
|
||||
// a reverse-charge / export customer, 25% domestically.
|
||||
vat_rate: vatRegistered ? vatRatePlan.defaultRate : 0,
|
||||
article_id: null,
|
||||
revenue_account: null,
|
||||
deduction_type: null,
|
||||
@@ -2087,6 +2135,15 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attention is one ochre sentence, not a banner (UI convention
|
||||
6). Silent for the normal 0% case; renders only when a
|
||||
Swedish rate is actually picked for a customer whose default
|
||||
is 0%, where it is lawful for taxed-where-performed supplies
|
||||
only. */}
|
||||
{showTaxedWherePerformedHint && (
|
||||
<AttnLine>{t('vat_taxed_where_performed_hint')}</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -76,12 +76,17 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
|
||||
let items: Record<string, unknown>[] = []
|
||||
if (!error && data) {
|
||||
try {
|
||||
// invoice_items has NO company_id column: it is scoped through its
|
||||
// parent invoice. Filtering on it made PostgREST answer 42703, which
|
||||
// fetchAllRows rethrows, so the copy flow hit the "kunde inte
|
||||
// laddas" panel for EVERY company. Tenancy is unaffected: the
|
||||
// invoices lookup above already pins copyFromId to this company, and
|
||||
// the invoice_items RLS policy joins back to the parent invoice.
|
||||
items = await fetchAllRows<Record<string, unknown>>(({ from, to }) =>
|
||||
supabase
|
||||
.from('invoice_items')
|
||||
.select('*')
|
||||
.eq('invoice_id', copyFromId)
|
||||
.eq('company_id', company.id)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The invoice editor's per-line Moms picker: rendered options vs the default.
|
||||
*
|
||||
* The bug this pins: the picker rendered getAvailableVatRates(), whose set for a
|
||||
* VAT-validated EU business or a non-EU business is a single locked 0%. A
|
||||
* Stockholm hotel could therefore not pick 12% for a hotel night sold to a
|
||||
* German company, even though that supply is taxed where it is performed
|
||||
* (ML 6 kap.) and carries Swedish VAT. Widening the DEFAULT instead would leave
|
||||
* a stale 25% line at 25% after switching to an EU customer: the opposite error.
|
||||
* So the two sets stay separate and are tested separately here.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
FALLBACK_VAT_RATE,
|
||||
hasSwedishVatToForeignBusiness,
|
||||
planCustomerSwitchVatSnap,
|
||||
resolveLineVatRates,
|
||||
} from '@/components/invoices/line-vat-rates'
|
||||
|
||||
const swedish = { customer_type: 'swedish_business' as const, vat_number_validated: false }
|
||||
const euValidated = { customer_type: 'eu_business' as const, vat_number_validated: true }
|
||||
const euUnvalidated = { customer_type: 'eu_business' as const, vat_number_validated: false }
|
||||
const nonEu = { customer_type: 'non_eu_business' as const, vat_number_validated: false }
|
||||
|
||||
describe('resolveLineVatRates: rendered options', () => {
|
||||
it('offers four rates for a validated EU business, 0% first', () => {
|
||||
const plan = resolveLineVatRates(euValidated)
|
||||
expect(plan.options.map((o) => o.rate)).toEqual([0, 25, 12, 6])
|
||||
expect(plan.options[0].treatment).toBe('reverse_charge')
|
||||
// The picker must be usable: that was the whole failure.
|
||||
expect(plan.isPickerLocked).toBe(false)
|
||||
})
|
||||
|
||||
it('offers four rates for a non-EU business, 0% (export) first', () => {
|
||||
const plan = resolveLineVatRates(nonEu)
|
||||
expect(plan.options.map((o) => o.rate)).toEqual([0, 25, 12, 6])
|
||||
expect(plan.options[0].treatment).toBe('export')
|
||||
expect(plan.isPickerLocked).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the unchanged domestic set for a Swedish customer', () => {
|
||||
const plan = resolveLineVatRates(swedish)
|
||||
expect(plan.options.map((o) => o.rate)).toEqual([25, 12, 6, 0])
|
||||
expect(plan.isPickerLocked).toBe(false)
|
||||
})
|
||||
|
||||
it('renders nothing before a customer is picked', () => {
|
||||
expect(resolveLineVatRates(null).options).toEqual([])
|
||||
expect(resolveLineVatRates(null).isPickerLocked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLineVatRates: the default', () => {
|
||||
it('keeps 0% as the default for a foreign business customer', () => {
|
||||
expect(resolveLineVatRates(euValidated).defaultRate).toBe(0)
|
||||
expect(resolveLineVatRates(nonEu).defaultRate).toBe(0)
|
||||
expect(resolveLineVatRates(euValidated).hasSingleDefault).toBe(true)
|
||||
expect(resolveLineVatRates(nonEu).hasSingleDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps 25% as the default domestically and for an unvalidated EU business', () => {
|
||||
expect(resolveLineVatRates(swedish).defaultRate).toBe(25)
|
||||
expect(resolveLineVatRates(euUnvalidated).defaultRate).toBe(25)
|
||||
expect(resolveLineVatRates(swedish).hasSingleDefault).toBe(false)
|
||||
expect(resolveLineVatRates(euUnvalidated).hasSingleDefault).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to 25% before a customer is picked (matches the empty form)', () => {
|
||||
expect(resolveLineVatRates(null).defaultRate).toBe(FALLBACK_VAT_RATE)
|
||||
expect(FALLBACK_VAT_RATE).toBe(25)
|
||||
})
|
||||
|
||||
it('never lets the default follow the widened option list', () => {
|
||||
// Regression guard: if the default were ever read off `options`, a foreign
|
||||
// business customer would start booking 25% by itself.
|
||||
for (const customer of [euValidated, nonEu]) {
|
||||
const plan = resolveLineVatRates(customer)
|
||||
expect(plan.defaultRates).toHaveLength(1)
|
||||
expect(plan.defaultRate).toBe(0)
|
||||
expect(plan.options.length).toBeGreaterThan(plan.defaultRates.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('only adopts an article rate that is in the DEFAULT set', () => {
|
||||
// The editor gates article-rate adoption on hasSingleDefault + defaultRates:
|
||||
// an article's stored rate is its domestic rate, so adopting 25% from the
|
||||
// article would silently put Swedish VAT on a reverse-charge invoice.
|
||||
const domestic = resolveLineVatRates(swedish)
|
||||
expect(domestic.hasSingleDefault).toBe(false)
|
||||
expect(domestic.defaultRates.some((r) => r.rate === 12)).toBe(true)
|
||||
|
||||
const foreign = resolveLineVatRates(euValidated)
|
||||
expect(foreign.hasSingleDefault).toBe(true)
|
||||
expect(foreign.defaultRates.some((r) => r.rate === 12)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planCustomerSwitchVatSnap', () => {
|
||||
it('moves lines still on the old default onto the new one', () => {
|
||||
// Swedish → validated EU business: inherited 25% must not survive as
|
||||
// Swedish VAT on what is now a reverse-charge invoice.
|
||||
const snaps = planCustomerSwitchVatSnap({
|
||||
items: [{ vat_rate: 25 }, { vat_rate: 25 }],
|
||||
previousDefaultRate: 25,
|
||||
nextDefaultRate: 0,
|
||||
})
|
||||
expect(snaps).toEqual([
|
||||
{ index: 0, rate: 0 },
|
||||
{ index: 1, rate: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a deliberate 12% hotel line alone', () => {
|
||||
// The line the Stockholm hotel explicitly set to 12% survives the switch:
|
||||
// it is lawful (taxed where performed) and the user chose it. The inherited
|
||||
// 25% line next to it still follows the new customer.
|
||||
const snaps = planCustomerSwitchVatSnap({
|
||||
items: [{ vat_rate: 12 }, { vat_rate: 25 }],
|
||||
previousDefaultRate: 25,
|
||||
nextDefaultRate: 0,
|
||||
})
|
||||
expect(snaps).toEqual([{ index: 1, rate: 0 }])
|
||||
})
|
||||
|
||||
it('moves an inherited 0% onto the domestic default in the reverse direction', () => {
|
||||
// EU → Swedish customer. The old code snapped only when the NEW customer
|
||||
// forced a single rate, so 0% lines silently survived onto a domestic
|
||||
// invoice and under-charged VAT.
|
||||
const snaps = planCustomerSwitchVatSnap({
|
||||
items: [{ vat_rate: 0 }],
|
||||
previousDefaultRate: 0,
|
||||
nextDefaultRate: 25,
|
||||
})
|
||||
expect(snaps).toEqual([{ index: 0, rate: 25 }])
|
||||
})
|
||||
|
||||
it('treats a line with no rate as inherited', () => {
|
||||
const snaps = planCustomerSwitchVatSnap({
|
||||
items: [{}, { vat_rate: null }],
|
||||
previousDefaultRate: 25,
|
||||
nextDefaultRate: 0,
|
||||
})
|
||||
expect(snaps).toEqual([
|
||||
{ index: 0, rate: 0 },
|
||||
{ index: 1, rate: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('never touches free-text rows', () => {
|
||||
const snaps = planCustomerSwitchVatSnap({
|
||||
items: [{ line_type: 'text', vat_rate: 0 }, { line_type: 'product', vat_rate: 0 }],
|
||||
previousDefaultRate: 0,
|
||||
nextDefaultRate: 25,
|
||||
})
|
||||
expect(snaps).toEqual([{ index: 1, rate: 25 }])
|
||||
})
|
||||
|
||||
it('does nothing when both customers share a default', () => {
|
||||
// e.g. Swedish → another Swedish customer, or EU → non-EU (both 0%).
|
||||
expect(
|
||||
planCustomerSwitchVatSnap({
|
||||
items: [{ vat_rate: 12 }, { vat_rate: 25 }],
|
||||
previousDefaultRate: 25,
|
||||
nextDefaultRate: 25,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSwedishVatToForeignBusiness', () => {
|
||||
it('is true only once a non-zero rate is actually selected', () => {
|
||||
const plan = resolveLineVatRates(euValidated)
|
||||
expect(hasSwedishVatToForeignBusiness({ plan, items: [{ vat_rate: 0 }] })).toBe(false)
|
||||
expect(hasSwedishVatToForeignBusiness({ plan, items: [{ vat_rate: 12 }] })).toBe(true)
|
||||
expect(
|
||||
hasSwedishVatToForeignBusiness({ plan, items: [{ vat_rate: 0 }, { vat_rate: 25 }] }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent for a domestic customer, where 25% is the normal case', () => {
|
||||
const plan = resolveLineVatRates(swedish)
|
||||
expect(hasSwedishVatToForeignBusiness({ plan, items: [{ vat_rate: 25 }] })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores free-text rows and an unpicked customer', () => {
|
||||
const plan = resolveLineVatRates(euValidated)
|
||||
expect(
|
||||
hasSwedishVatToForeignBusiness({ plan, items: [{ line_type: 'text', vat_rate: 25 }] }),
|
||||
).toBe(false)
|
||||
expect(
|
||||
hasSwedishVatToForeignBusiness({
|
||||
plan: resolveLineVatRates(null),
|
||||
items: [{ vat_rate: 25 }],
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../NewInvoiceDialog.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
/**
|
||||
* Regression pin for the copy-invoice flow, same class as the article-delete
|
||||
* phantom column fixed in #1188. `invoice_items` has NO `company_id` column:
|
||||
* it is scoped through its parent invoice. Filtering on it makes PostgREST
|
||||
* answer 42703, fetchAllRows rethrows, and the dialog renders the
|
||||
* "kunde inte laddas" panel for EVERY company.
|
||||
*
|
||||
* This is a component, and the repo does not render components in tests, so
|
||||
* there is no behavioral harness that can observe the failing request: pin the
|
||||
* query shape in the source instead.
|
||||
*/
|
||||
describe('NewInvoiceDialog copy-source query shape', () => {
|
||||
const itemsQuery = (() => {
|
||||
const start = SRC.indexOf("from('invoice_items')")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
return SRC.slice(start, SRC.indexOf('.range(from, to)', start))
|
||||
})()
|
||||
|
||||
it('must not filter invoice_items by company_id (column does not exist)', () => {
|
||||
expect(itemsQuery).not.toContain("eq('company_id'")
|
||||
})
|
||||
|
||||
it('still scopes the items to the parent invoice', () => {
|
||||
expect(itemsQuery).toContain("eq('invoice_id', copyFromId)")
|
||||
})
|
||||
|
||||
it('keeps the company filter on the parent invoices lookup (tenancy)', () => {
|
||||
const start = SRC.indexOf("from('invoices')")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
const invoiceQuery = SRC.slice(start, SRC.indexOf('.single()', start))
|
||||
expect(invoiceQuery).toContain("eq('id', copyFromId)")
|
||||
expect(invoiceQuery).toContain("eq('company_id', company.id)")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
getAvailableVatRates,
|
||||
getPermittedVatRates,
|
||||
type VatRateOption,
|
||||
} from '@/lib/invoices/vat-rules'
|
||||
import type { CustomerType } from '@/types'
|
||||
|
||||
/**
|
||||
* The invoice editor's per-line Moms picker derives FOUR different behaviours
|
||||
* from the customer's VAT rules, and they must not share one set of rates:
|
||||
*
|
||||
* - which options are RENDERED → getPermittedVatRates() (the lawful set)
|
||||
* - what a new line STARTS on → getAvailableVatRates() (the default)
|
||||
* - whether an article's rate is ADOPTED → the default set
|
||||
* - what a customer switch SNAPS to → the default
|
||||
*
|
||||
* Under huvudregeln (ML 6 kap. 34 §, Article 44 VAT Directive) a B2B service is
|
||||
* taxed where the buyer is established, so 0% (reverse charge for a VAT-
|
||||
* validated EU business, export outside the EU) is the right DEFAULT for a
|
||||
* foreign business customer. It is not the only lawful rate: the ML 6 kap.
|
||||
* exceptions taxed where the supply is performed (fastighetstjänster 25%,
|
||||
* persontransporter 6%, korttidsuthyrning of transport vehicles 25%,
|
||||
* restaurang/catering 12%, admission to cultural and sports events 6%) carry
|
||||
* Swedish VAT even when the buyer is a German or a US company. A Stockholm
|
||||
* hotel night sold to a German company is such a supply, so the picker has to
|
||||
* OFFER 12% while still DEFAULTING to 0%.
|
||||
*
|
||||
* Kept in a plain module (no JSX, no hooks) so the rules are unit-testable:
|
||||
* the repo does not render components in tests.
|
||||
*/
|
||||
|
||||
/** The minimum this module needs to know about the picked customer. */
|
||||
export interface VatRateCustomer {
|
||||
customer_type: CustomerType
|
||||
vat_number_validated?: boolean | null
|
||||
}
|
||||
|
||||
/** One invoice line as the editor's form holds it. */
|
||||
export interface VatRateLine {
|
||||
vat_rate?: number | null
|
||||
line_type?: 'product' | 'text' | null
|
||||
}
|
||||
|
||||
export interface LineVatRatePlan {
|
||||
/** Rendered picker options: the PERMITTED set. 0% stays first. */
|
||||
options: VatRateOption[]
|
||||
/** The DEFAULT set. One locked 0% option for a foreign business customer. */
|
||||
defaultRates: VatRateOption[]
|
||||
/** The rate a new line starts on and a customer switch snaps to. */
|
||||
defaultRate: number
|
||||
/** True when the DEFAULT set holds exactly one rate (foreign business, 0%). */
|
||||
hasSingleDefault: boolean
|
||||
/** True only when there is genuinely nothing to choose between. */
|
||||
isPickerLocked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate the empty form's first line carries, before any customer is picked.
|
||||
* Mirrors the editor's defaultValues.
|
||||
*/
|
||||
export const FALLBACK_VAT_RATE = 25
|
||||
|
||||
export function resolveLineVatRates(
|
||||
customer: VatRateCustomer | null | undefined,
|
||||
): LineVatRatePlan {
|
||||
if (!customer) {
|
||||
return {
|
||||
options: [],
|
||||
defaultRates: [],
|
||||
defaultRate: FALLBACK_VAT_RATE,
|
||||
hasSingleDefault: false,
|
||||
isPickerLocked: false,
|
||||
}
|
||||
}
|
||||
const validated = customer.vat_number_validated ?? false
|
||||
const defaultRates = getAvailableVatRates(customer.customer_type, validated)
|
||||
const options = getPermittedVatRates(customer.customer_type, validated)
|
||||
return {
|
||||
options,
|
||||
defaultRates,
|
||||
defaultRate: defaultRates[0]?.rate ?? FALLBACK_VAT_RATE,
|
||||
hasSingleDefault: defaultRates.length === 1,
|
||||
isPickerLocked: options.length === 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lines a customer switch may move onto the new customer's default rate.
|
||||
*
|
||||
* Only lines still sitting on the PREVIOUS customer's default are moved: those
|
||||
* were never chosen, they were inherited, so leaving them behind is what
|
||||
* produced the two stale-rate bugs (25% surviving onto a reverse-charge
|
||||
* invoice, 0% surviving onto a domestic one).
|
||||
*
|
||||
* A line the user moved off that default is a deliberate choice and is left
|
||||
* alone. Snapping every line was the older behaviour and it destroyed the
|
||||
* lawful case this whole rule exists for: a 12% hotel night or a 6% event
|
||||
* ticket sold to a foreign business, taxed where the supply is performed.
|
||||
* Nothing on a line distinguishes those from ordinary consulting, so the
|
||||
* user's explicit rate is the only signal there is, and overwriting it would
|
||||
* be guessing.
|
||||
*
|
||||
* Free-text rows (line_type 'text') carry no amounts and never book; they are
|
||||
* left untouched so a switch cannot dirty the form with meaningless rates.
|
||||
*/
|
||||
export function planCustomerSwitchVatSnap(params: {
|
||||
items: VatRateLine[]
|
||||
previousDefaultRate: number
|
||||
nextDefaultRate: number
|
||||
}): Array<{ index: number; rate: number }> {
|
||||
const { items, previousDefaultRate, nextDefaultRate } = params
|
||||
if (previousDefaultRate === nextDefaultRate) return []
|
||||
|
||||
const snaps: Array<{ index: number; rate: number }> = []
|
||||
items.forEach((item, index) => {
|
||||
if (item?.line_type === 'text') return
|
||||
// An absent rate falls back to the old default, exactly like the server
|
||||
// does (item.vat_rate ?? getVatRules().rate), so such a line still follows.
|
||||
const current = item?.vat_rate ?? previousDefaultRate
|
||||
if (current !== previousDefaultRate) return
|
||||
snaps.push({ index, rate: nextDefaultRate })
|
||||
})
|
||||
return snaps
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a Swedish rate is actually selected on an invoice whose customer
|
||||
* defaults to 0% (validated EU business or non-EU business). That is lawful
|
||||
* only for the supplies taxed where they are performed, so it earns the single
|
||||
* ochre attention sentence. Silent otherwise: 0% is the normal case and needs
|
||||
* no commentary.
|
||||
*/
|
||||
export function hasSwedishVatToForeignBusiness(params: {
|
||||
plan: LineVatRatePlan
|
||||
items: VatRateLine[]
|
||||
}): boolean {
|
||||
const { plan, items } = params
|
||||
if (!plan.hasSingleDefault || plan.defaultRate !== 0) return false
|
||||
return items.some((item) => item?.line_type !== 'text' && (item?.vat_rate ?? 0) > 0)
|
||||
}
|
||||
@@ -19,7 +19,12 @@ import type { KPIPreferences } from '@/types'
|
||||
|
||||
interface KPISettingsDialogProps {
|
||||
preferences: KPIPreferences
|
||||
onSave: (prefs: KPIPreferences) => void
|
||||
/**
|
||||
* Resolves true when the layout was actually stored. The dialog awaits it, so
|
||||
* `saving` gets a chance to render, and stays open on false so the draft is
|
||||
* still there to retry with.
|
||||
*/
|
||||
onSave: (prefs: KPIPreferences) => Promise<boolean>
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
@@ -31,6 +36,10 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
function handleOpen(isOpen: boolean) {
|
||||
// Esc and click-outside must not discard a draft that is mid-save: the save
|
||||
// could still fail, and the draft is the only copy of what the user picked.
|
||||
// Bounded wait, the request carries a 15s deadline.
|
||||
if (!isOpen && saving) return
|
||||
if (isOpen) setDraft(preferences)
|
||||
setOpen(isOpen)
|
||||
}
|
||||
@@ -71,9 +80,21 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
setDraft(getDefaultPreferences())
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(draft)
|
||||
setOpen(false)
|
||||
/**
|
||||
* Await the save and close only if it landed.
|
||||
*
|
||||
* Closing first made the parent's `saving` state unrenderable and, worse, made
|
||||
* a failed save look identical to a successful one: the dialog vanished, the
|
||||
* grid showed the draft, and the layout was gone on the next page load. On
|
||||
* failure the parent shows the one toast that says why and this dialog stays
|
||||
* open with the draft intact.
|
||||
*/
|
||||
async function handleSave() {
|
||||
// Closes the double-click race before React has re-rendered the disabled
|
||||
// button; a second PUT would race the first over the same row.
|
||||
if (saving) return
|
||||
const saved = await onSave(draft)
|
||||
if (saved) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -202,7 +223,7 @@ export function KPISettingsDialog({ preferences, onSave, saving }: KPISettingsDi
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" disabled={saving}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
loadKPIPreferences,
|
||||
LOAD_KPI_PREFERENCES_TIMEOUT_MS,
|
||||
} from '@/components/kpi/load-preferences'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
const STORED: KPIPreferences = {
|
||||
visibleKpis: ['netResult', 'cashPosition'],
|
||||
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
|
||||
accountOverrides: { cashPosition: ['1930', '1940'] },
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** A fetch that never resolves and rejects the way the platform does on abort. */
|
||||
function hangingFetch() {
|
||||
return vi.fn((_url: unknown, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const err = new Error('The operation was aborted due to timeout')
|
||||
err.name = 'TimeoutError'
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('loadKPIPreferences', () => {
|
||||
it('GETs the preferences endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: STORED }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await loadKPIPreferences()
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/kpi/preferences')
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the stored row the route answered with', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: STORED }))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toEqual({ ok: true, preferences: STORED })
|
||||
})
|
||||
|
||||
it('reports a 401 as a server failure carrying the status-map sentence', async () => {
|
||||
// The page reads status 401/403 off this arm to drop the retry: a retry
|
||||
// cannot outlive an expired session, signing in again can.
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 401 }))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 401,
|
||||
message: 'Din session har gått ut. Logga in igen.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a 403 with its status so the page can drop the retry', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 403 }))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'server', status: 403 })
|
||||
})
|
||||
|
||||
it('reports a 500 as a failure instead of fabricating defaults', async () => {
|
||||
// The finding: this arm used to fall into a silent catch, the page seeded
|
||||
// the settings dialog from getDefaultPreferences(), and the next save PUT
|
||||
// that defaults-based object over the user's stored layout. The terse
|
||||
// envelope message is not Swedish user-prose, so getErrorMessage resolves
|
||||
// it through the status map.
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Serverfel' } }, 500))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 500,
|
||||
message: 'Ett oväntat serverfel uppstod. Försök igen senare.',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the status message when the error body is not JSON', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 502,
|
||||
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a server error in English for an English UI', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('', { status: 401 }))
|
||||
|
||||
const result = await loadKPIPreferences({ locale: 'en' })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reason: 'server',
|
||||
message: 'Your session has expired. Please sign in again.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a thrown request as a network failure, not as defaults', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'network',
|
||||
message: 'Något gick fel. Försök igen.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a hung request as a timeout instead of hanging the page', async () => {
|
||||
globalThis.fetch = hangingFetch()
|
||||
|
||||
const result = await loadKPIPreferences({ timeoutMs: 20 })
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'timeout' })
|
||||
})
|
||||
|
||||
it('bounds the request with an abort signal on the default deadline', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: STORED }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await loadKPIPreferences()
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(LOAD_KPI_PREFERENCES_TIMEOUT_MS).toBe(15_000)
|
||||
})
|
||||
|
||||
it('treats a 200 without a preferences object as a failed read, never as defaults', async () => {
|
||||
// Unlike the save path (where a 2xx means the row was already written and
|
||||
// the sent payload is the closest truth), a read has nothing truthful to
|
||||
// substitute: an ok result here would flow into the dialog draft and be
|
||||
// saved over the stored row.
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'network' })
|
||||
})
|
||||
|
||||
it('treats a 200 with an incomplete preferences object as a failed read', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: { visibleKpis: null } }))
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'network' })
|
||||
})
|
||||
|
||||
it('treats a 200 whose body is not JSON as a failed read', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response('<!doctype html><title>proxy</title>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await loadKPIPreferences()
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'network' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
saveKPIPreferences,
|
||||
SAVE_KPI_PREFERENCES_TIMEOUT_MS,
|
||||
} from '@/components/kpi/save-preferences'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
const PREFS: KPIPreferences = {
|
||||
visibleKpis: ['netResult', 'cashPosition'],
|
||||
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
|
||||
accountOverrides: { cashPosition: ['1930', '1940'] },
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** A fetch that never resolves and rejects the way the platform does on abort. */
|
||||
function hangingFetch() {
|
||||
return vi.fn((_url: unknown, init?: RequestInit) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const err = new Error('The operation was aborted due to timeout')
|
||||
err.name = 'TimeoutError'
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('saveKPIPreferences', () => {
|
||||
it('PUTs the complete preferences object as JSON', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: PREFS }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/kpi/preferences')
|
||||
expect(init.method).toBe('PUT')
|
||||
// Every key present: the route treats them all as optional and merges over
|
||||
// the stored row, so a sparse body would leave the page rendering keys the
|
||||
// server never confirmed.
|
||||
expect(JSON.parse(init.body as string)).toEqual(PREFS)
|
||||
})
|
||||
|
||||
it('returns the row the route echoed back, not the draft', async () => {
|
||||
// The route answers with the merge of the payload over what was stored, so
|
||||
// the echo can differ from what was sent. That is the value the page renders.
|
||||
const stored: KPIPreferences = {
|
||||
visibleKpis: ['netResult', 'cashPosition'],
|
||||
kpiOrder: ['cashPosition', 'netResult', 'vatLiability'],
|
||||
accountOverrides: { cashPosition: ['1930', '1940'], vatLiability: ['2611'] },
|
||||
}
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: stored }))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({ ok: true, preferences: stored })
|
||||
})
|
||||
|
||||
it('reports a 500 instead of reporting nothing', async () => {
|
||||
// The finding: this branch threw into an empty catch, the dialog closed, and
|
||||
// the layout was gone on the next page load.
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ error: 'Kunde inte spara nyckeltalen. Försök igen.' }, 500))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 500,
|
||||
message: 'Kunde inte spara nyckeltalen. Försök igen.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the route validation message on 400', async () => {
|
||||
// The account-override validator answers a plain-string envelope; an unknown
|
||||
// account number must reach the user, not vanish.
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ error: { code: 'VALIDATION_ERROR', message: 'Ogiltigt kontonummer i cashPosition.' } },
|
||||
400,
|
||||
),
|
||||
)
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 400,
|
||||
message: 'Ogiltigt kontonummer i cashPosition.',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the status message when the error body is not JSON', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: 502,
|
||||
message: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a server error in English for an English UI', async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ error: { code: 'INTERNAL_ERROR', message: 'Serverfel' } }, 500))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS, locale: 'en' })
|
||||
|
||||
expect(result).toMatchObject({ reason: 'server', message: 'Internal server error.' })
|
||||
})
|
||||
|
||||
it('reports a thrown request as a network failure, not as success', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('fetch failed'))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'network',
|
||||
message: 'Något gick fel. Försök igen.',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a hung request as a timeout, not as a generic failure', async () => {
|
||||
globalThis.fetch = hangingFetch()
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS, timeoutMs: 20 })
|
||||
|
||||
// A timeout on a write is ambiguous (the row may have been written), so the
|
||||
// dialog needs to tell it apart to say "reload and check".
|
||||
expect(result).toEqual({ ok: false, reason: 'timeout' })
|
||||
})
|
||||
|
||||
it('bounds the request with an abort signal on the default deadline', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ data: PREFS }))
|
||||
globalThis.fetch = fetchMock
|
||||
|
||||
await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
const init = fetchMock.mock.calls[0][1] as RequestInit
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal)
|
||||
// Never shorter than the mutation deadline used elsewhere: aborting early
|
||||
// reports a failure for a write that may have landed.
|
||||
expect(SAVE_KPI_PREFERENCES_TIMEOUT_MS).toBe(15_000)
|
||||
})
|
||||
|
||||
it('never yields undefined preferences on a 2xx with no echoed row', async () => {
|
||||
// A 200 whose body carries no `data` used to be destructured and assigned
|
||||
// straight into the page state, and both the grid and the dialog read
|
||||
// .visibleKpis off it without a guard.
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({}))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({ ok: true, preferences: PREFS })
|
||||
})
|
||||
|
||||
it('ignores an echoed row that is not a complete preferences object', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse({ data: { visibleKpis: null } }))
|
||||
|
||||
const result = await saveKPIPreferences({ preferences: PREFS })
|
||||
|
||||
expect(result).toEqual({ ok: true, preferences: PREFS })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
import { KPI_PREFERENCES_URL, readPreferencesBody } from './save-preferences'
|
||||
|
||||
/**
|
||||
* Read the stored Nyckeltal layout and report exactly what happened.
|
||||
*
|
||||
* Why this is a module and not five lines in the page: the effect it replaces
|
||||
* fell back to `getDefaultPreferences()` on every failed read, silently. That
|
||||
* default object then seeded the settings dialog, the dialog sends the
|
||||
* complete draft on save, and the PUT route merges the payload over the
|
||||
* stored row key by key, so a payload that carries every key replaces the row
|
||||
* outright. A read failure thereby became permanent data loss on the user's
|
||||
* next save: their stored layout was overwritten by a defaults-based object
|
||||
* they never chose. Fixing the write path alone cannot help, because the
|
||||
* thing being written is already wrong before the write starts.
|
||||
*
|
||||
* So this never fabricates a value. Every outcome that is not a complete
|
||||
* preferences object read from the server is a failure the page must show,
|
||||
* and while the read has failed the page keeps the layout unknown: the grid
|
||||
* renders nothing preference-driven and the control that opens the settings
|
||||
* dialog stays disabled, because a save seeded from anything but the stored
|
||||
* row is exactly the overwrite this module exists to prevent.
|
||||
*
|
||||
* The failure union is `ActionFailure`, same as the save path, so the page
|
||||
* describes a failed read through the same `failureDescription()` vocabulary
|
||||
* as a failed save.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deadline for the read.
|
||||
*
|
||||
* Same value as the save deadline for consistency, though the reasoning is
|
||||
* simpler here: a read has no write-ambiguity, the bound only exists so a
|
||||
* hung GET surfaces the retry line instead of an eternal skeleton.
|
||||
*/
|
||||
export const LOAD_KPI_PREFERENCES_TIMEOUT_MS = 15_000
|
||||
|
||||
export type LoadKPIPreferencesResult =
|
||||
| { ok: true; preferences: KPIPreferences }
|
||||
| ActionFailure
|
||||
|
||||
export interface LoadKPIPreferencesOptions {
|
||||
/** UI locale, so a server error is reported in the language the user reads. */
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export async function loadKPIPreferences({
|
||||
locale = 'sv',
|
||||
timeoutMs = LOAD_KPI_PREFERENCES_TIMEOUT_MS,
|
||||
}: LoadKPIPreferencesOptions = {}): Promise<LoadKPIPreferencesResult> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
KPI_PREFERENCES_URL,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs, description: `get ${KPI_PREFERENCES_URL}` },
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
// A body that is not JSON (an HTML error page, an empty 502) leaves
|
||||
// `null`, and getErrorMessage falls back to the status map. 401/403 land
|
||||
// here too: the page reads the status off this arm to drop the retry,
|
||||
// since retrying an expired session cannot succeed.
|
||||
const body = await res.json().catch(() => null)
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: getErrorMessage(body, { statusCode: res.status, locale }),
|
||||
}
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => null)
|
||||
const preferences = readPreferencesBody(body)
|
||||
if (!preferences) {
|
||||
// A 2xx whose body is not a complete preferences object is a FAILED
|
||||
// read, never a license to substitute defaults: this is the opposite
|
||||
// call from the save path, where a 2xx means the row was already
|
||||
// written and the sent payload is the closest truth. Reported on the
|
||||
// `network` arm (a corrupt or rewritten body is the truncated-body
|
||||
// case that arm documents) because the recourse is the same: retry.
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(null, { locale }) }
|
||||
}
|
||||
|
||||
return { ok: true, preferences }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
import type { KPIPreferences } from '@/types'
|
||||
|
||||
/**
|
||||
* Save the Nyckeltal layout and report exactly what happened.
|
||||
*
|
||||
* Why this is a module and not four lines in the page: the handler it replaces
|
||||
* had an empty catch commented "Silently fail: user can retry", and that catch
|
||||
* covered both the `!res.ok` throw and every thrown fetch, so a rejected save
|
||||
* was indistinguishable from a successful one. The settings dialog closed, the grid
|
||||
* kept rendering the draft the user had just picked, and the next page load read
|
||||
* the untouched row back from `extension_data` and reverted the layout. "The
|
||||
* user can retry" only holds if the user knows there is something to retry.
|
||||
*
|
||||
* `postAction` cannot serve this call: it is a bodyless POST that deliberately
|
||||
* ignores the success body, whereas this is a PUT carrying the preferences and
|
||||
* the echoed row is what the page renders afterwards. The failure union is
|
||||
* `ActionFailure` all the same, so the page describes a failed save through the
|
||||
* same `failureDescription()` as a failed download elsewhere in the app.
|
||||
*
|
||||
* The request is bounded for the reason every interactive mutation is: an
|
||||
* unbounded PUT leaves the dialog's Save button spinning forever if an instance
|
||||
* hangs, and the only recourse the user has is to click again.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deadline for the save.
|
||||
*
|
||||
* Same value as POST_ACTION_TIMEOUT_MS, and deliberately not shorter: aborting
|
||||
* early reports a failure for a write that may well have landed, and on this
|
||||
* surface that ambiguity would push the user into a second save.
|
||||
*/
|
||||
export const SAVE_KPI_PREFERENCES_TIMEOUT_MS = 15_000
|
||||
|
||||
export const KPI_PREFERENCES_URL = '/api/kpi/preferences'
|
||||
|
||||
export type SaveKPIPreferencesResult =
|
||||
| { ok: true; preferences: KPIPreferences }
|
||||
| ActionFailure
|
||||
|
||||
export interface SaveKPIPreferencesOptions {
|
||||
/** Sent whole: the route treats every key as optional, and a sparse PUT would leave the page rendering keys it never confirmed. */
|
||||
preferences: KPIPreferences
|
||||
/** UI locale, so a server error is reported in the language the user reads. */
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a preferences object out of a `{ data: <row> }` response body.
|
||||
*
|
||||
* Both routes answer this shape: the PUT echoes the merge of the caller's
|
||||
* payload over what was already stored, and the GET returns the stored row
|
||||
* merged with defaults. Anything that is not a complete preferences object
|
||||
* (an empty 200, a proxy that rewrote the body) yields `null`: assigning
|
||||
* `undefined` into the page's preferences state used to be a live crash path,
|
||||
* since both the grid and the dialog read `.visibleKpis` off it without a
|
||||
* guard. Shared with `loadKPIPreferences()`, where a `null` means the read
|
||||
* failed rather than "render the payload instead".
|
||||
*/
|
||||
export function readPreferencesBody(body: unknown): KPIPreferences | null {
|
||||
if (!body || typeof body !== 'object') return null
|
||||
const data = (body as { data?: unknown }).data
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const candidate = data as Partial<KPIPreferences>
|
||||
const overrides = candidate.accountOverrides
|
||||
if (
|
||||
!Array.isArray(candidate.visibleKpis) ||
|
||||
!Array.isArray(candidate.kpiOrder) ||
|
||||
!overrides ||
|
||||
typeof overrides !== 'object' ||
|
||||
Array.isArray(overrides)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
visibleKpis: candidate.visibleKpis,
|
||||
kpiOrder: candidate.kpiOrder,
|
||||
accountOverrides: overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveKPIPreferences({
|
||||
preferences,
|
||||
locale = 'sv',
|
||||
timeoutMs = SAVE_KPI_PREFERENCES_TIMEOUT_MS,
|
||||
}: SaveKPIPreferencesOptions): Promise<SaveKPIPreferencesResult> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
KPI_PREFERENCES_URL,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(preferences),
|
||||
},
|
||||
{ timeoutMs, description: `put ${KPI_PREFERENCES_URL}` },
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
// A body that is not JSON (an HTML error page, an empty 502) leaves
|
||||
// `null`, and getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: getErrorMessage(body, { statusCode: res.status, locale }),
|
||||
}
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => null)
|
||||
// A 2xx means the row was written, so this is not a failure even when the
|
||||
// echo is unusable: reporting "not saved" for a save that landed is the
|
||||
// same lie as the old silent catch, pointing the other way. The payload the
|
||||
// caller sent is then the closest truthful thing to render.
|
||||
return { ok: true, preferences: readPreferencesBody(body) ?? preferences }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,13 @@ export default function ReviewCard({
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
// Map the parsed body plus the status, never the raw response text:
|
||||
// the route answers thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and throwing the raw JSON text
|
||||
// (or "[object Object]") discards the route's own Swedish reason.
|
||||
const body = await res.json().catch(() => null)
|
||||
setVerifyError(getUserErrorMessage(body, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +177,9 @@ export default function ReviewCard({
|
||||
body: JSON.stringify({ company_id: companyId }),
|
||||
})
|
||||
if (!verifyRes.ok) {
|
||||
const text = await verifyRes.text()
|
||||
throw new Error(text || `HTTP ${verifyRes.status}`)
|
||||
const body = await verifyRes.json().catch(() => null)
|
||||
setVerifyError(getUserErrorMessage(body, { statusCode: verifyRes.status }))
|
||||
return
|
||||
}
|
||||
|
||||
onVerified()
|
||||
|
||||
@@ -82,10 +82,16 @@ interface SkatteverketPanelProps {
|
||||
fiscalYearEnd?: { year: number; month: number }
|
||||
hasData: boolean
|
||||
/**
|
||||
* True when the local pre-flight checks (VatChecksCard, section 1 of the
|
||||
* page) found ERRORs. Blocks validate/submit: SKV only validates internal
|
||||
* arithmetic, so a locally broken declaration would pass their checks and
|
||||
* still be materially wrong.
|
||||
* True when the momsdeklaration's filing gate is closed. Blocks
|
||||
* validate/submit: SKV only validates internal arithmetic, so a locally
|
||||
* broken declaration would pass their checks and still be materially wrong.
|
||||
*
|
||||
* The panel deliberately owns NO gate logic of its own. The value is
|
||||
* `isFilingBlocked(checks)` (`lib/reports/vat-filing-gate.ts`) computed once
|
||||
* in VatDeclarationView over the exact same check array that feeds the
|
||||
* "Kontroll av underlaget" banner and the stegen counters, so this button
|
||||
* can never be enabled while that section claims (or hides) a problem.
|
||||
* Never re-derive it here from a narrower source.
|
||||
*/
|
||||
localBlocked: boolean
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { VatChecksCard } from '@/components/reports/VatChecksCard'
|
||||
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
|
||||
import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration'
|
||||
import {
|
||||
isFilingBlocked,
|
||||
withRcBasisGapFindings,
|
||||
type RcBasisGapScan,
|
||||
} from '@/lib/reports/vat-filing-gate'
|
||||
import { Table, TableBody } from '@/components/ui/table'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import dynamic from 'next/dynamic'
|
||||
@@ -1483,6 +1489,13 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
// to automatic so stale step choices never survive a context change.
|
||||
const [chosenStep, setChosenStep] = useState<number | null>(null)
|
||||
const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null)
|
||||
// Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard)
|
||||
// because the filing gate lives here and the worklist unmounts as soon as
|
||||
// the user leaves steg 1. Tagged with the PERIOD it was requested for (see
|
||||
// gapScanPeriodKey), so a korrigering refetches without the gate falling
|
||||
// open in between; count === null means "not known" (scan failed), which is
|
||||
// deliberately NOT the same as zero.
|
||||
const [gapScan, setGapScan] = useState<{ key: string; count: number | null } | null>(null)
|
||||
|
||||
// Company settings drive both the momsregistrerad gate and the default
|
||||
// periodicity (moms_period in Inställningar). Applied once per company the
|
||||
@@ -1542,6 +1555,23 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
? null
|
||||
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
|
||||
|
||||
// Period identity WITHOUT the retry counter, used to decide whether the gap
|
||||
// scan below still speaks for what is on screen. The scan re-runs on every
|
||||
// retryKey bump (each korrigering), but the last settled count stays in
|
||||
// force until the new one lands: falling back to `pending` mid-korrigering
|
||||
// would drop the gap finding out of `checks` for one round trip, and with
|
||||
// the aggregate check silent (the tolerance case this gate exists for) the
|
||||
// green "Inga fel hittades i underlaget för perioden" would render right
|
||||
// above the vouchers still left in the worklist, with Skicka enabled. That
|
||||
// is the exact regression being fixed, and steg 1 stays mounted across a
|
||||
// korrigering, so the user would be looking straight at it. Stale-but-closed
|
||||
// is the safe direction, and it matches the declaration itself, which stays
|
||||
// on screen (dimmed) while the next one loads.
|
||||
const gapScanPeriodKey =
|
||||
periodType === null
|
||||
? null
|
||||
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}`
|
||||
|
||||
useEffect(() => {
|
||||
setChosenStep(null)
|
||||
setBookingStatus(null)
|
||||
@@ -1578,6 +1608,37 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
}
|
||||
}, [fetchKey, periodType, year, period, fiscalPeriodId])
|
||||
|
||||
// The per-verifikat gap scan runs on the same key as the declaration, so a
|
||||
// korrigering (which bumps retryKey via onCorrected) re-verifies the gate
|
||||
// instead of leaving it stuck on the pre-correction count.
|
||||
useEffect(() => {
|
||||
if (!fetchKey || periodType === null || !gapScanPeriodKey) return
|
||||
const params = new URLSearchParams({
|
||||
periodType,
|
||||
year: String(year),
|
||||
period: String(period),
|
||||
})
|
||||
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
|
||||
let cancelled = false
|
||||
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
|
||||
.then(async (res) => {
|
||||
const json = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
// count === null is a settled FAILURE, and unknown is not "inga
|
||||
// brister": it becomes a visible warning row (so the banner cannot
|
||||
// claim all-clear on a check that never ran) but does not block, or a
|
||||
// network hiccup would lock the user out of a statutory deadline.
|
||||
if (!res.ok || json?.error) setGapScan({ key: gapScanPeriodKey, count: null })
|
||||
else setGapScan({ key: gapScanPeriodKey, count: (json?.data?.gaps ?? []).length })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGapScan({ key: gapScanPeriodKey, count: null })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchKey, gapScanPeriodKey, periodType, year, period, fiscalPeriodId])
|
||||
|
||||
// Derived fetch state: the previous declaration stays visible (dimmed)
|
||||
// while the next period loads.
|
||||
const upToDate = result !== null && result.key === fetchKey
|
||||
@@ -1588,8 +1649,35 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
// Local pre-flight checks on the calculated declaration. Computed here (not
|
||||
// in SkatteverketPanel) so every user sees them: they gate direct submission
|
||||
// but concern manual filers just as much.
|
||||
const checks = data ? runVatDeclarationChecks(data.rutor) : []
|
||||
const checksBlocked = checks.some((c) => c.status === 'ERROR')
|
||||
//
|
||||
// The per-verifikat gap scan is folded in BEFORE anything derives from the
|
||||
// list. The aggregate checks compare period totals with a tolerance that
|
||||
// scales with period size, so they can stay silent while individual
|
||||
// verifikat still miss their basbelopp: reading them alone let the green
|
||||
// "Inga fel hittades i underlaget för perioden" render directly above the
|
||||
// worklist of those verifikat, with Skicka enabled. Banner, stegen and the
|
||||
// send gate now all read this ONE array. The three scan states are kept
|
||||
// apart: only a settled count of zero is allowed to mean "inga brister".
|
||||
const gapScanSettled = gapScan !== null && gapScan.key === gapScanPeriodKey
|
||||
const rcBasisScan: RcBasisGapScan = !gapScanSettled
|
||||
? { status: 'pending' }
|
||||
: gapScan.count === null
|
||||
? { status: 'unavailable' }
|
||||
: { status: 'scanned', gapCount: gapScan.count }
|
||||
// The declaration carries the 2645/2647 totals (rcInputAccountTotals), so
|
||||
// RC_INPUT_VAT_MISMATCH compares rutor 30-32 against the reverse-charge INPUT
|
||||
// accounts instead of the ruta 48 aggregate. Without them, 50 000 kr of
|
||||
// fiktiv utgående moms with nothing on 2645 sits silently behind 60 000 kr of
|
||||
// ordinary 2641 and the user pays in moms they were entitled to deduct.
|
||||
// rcInputTotalsFromDeclaration returns undefined (not an empty map) when a
|
||||
// response predates the field, which keeps the fallback honest.
|
||||
const checks = data
|
||||
? withRcBasisGapFindings(
|
||||
runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)),
|
||||
rcBasisScan,
|
||||
)
|
||||
: []
|
||||
const checksBlocked = isFilingBlocked(checks)
|
||||
const errorCount = checks.filter((c) => c.status === 'ERROR').length
|
||||
const warningCount = checks.filter((c) => c.status === 'WARNING').length
|
||||
// Latch the automatic landing step once per period, as a render-phase
|
||||
@@ -1598,8 +1686,10 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||||
// korrigering can clear the aggregate error while the Kontrollera worklist
|
||||
// still holds broken vouchers, and the view would jump to Granska under
|
||||
// their feet. The period-change effect below resets chosenStep to null,
|
||||
// which re-arms this latch for the next period.
|
||||
if (chosenStep === null && upToDate && data && !error) {
|
||||
// which re-arms this latch for the next period. It also waits for the gap
|
||||
// scan: latching on the aggregate alone landed the user on Granska while
|
||||
// steg 1 still held a blocking worklist.
|
||||
if (chosenStep === null && upToDate && data && !error && gapScanSettled) {
|
||||
setChosenStep(checksBlocked ? 1 : 2)
|
||||
}
|
||||
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
|
||||
|
||||
@@ -25,7 +25,10 @@ import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { isAllowedSkvPopupOrigin } from '@/lib/skatteverket/popup-origin'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
|
||||
import {
|
||||
resolveRunAgiSubmission,
|
||||
type AgiSubmissionState,
|
||||
} from '@/lib/salary/agi-submission-state'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface AGIPanelProps {
|
||||
@@ -40,6 +43,11 @@ interface AGIPanelProps {
|
||||
/**
|
||||
* Per-period submission record, owned by the parent (via useAgiSubmission)
|
||||
* so the progress rail and hero can render the same state machine.
|
||||
*
|
||||
* Deliberately period-scoped, matching Skatteverket: the underlag, the
|
||||
* granskningsunderlag lock and lasUpp all address a redovisningsperiod, not
|
||||
* a run. What is run-scoped is the filing receipt, and the panel narrows the
|
||||
* record itself (see `runSubmission` below) rather than asking the parent to.
|
||||
*/
|
||||
submission: AgiSubmissionState | null
|
||||
/** Refetch the submission record after a state-changing action. */
|
||||
@@ -162,11 +170,25 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
|
||||
|
||||
// ── Derived filing state (needed by hooks, so derived before any return) ──
|
||||
// Two scopes live side by side below, deliberately.
|
||||
//
|
||||
// PERIOD scope (`submission`): the in-flight machine. Skatteverket locks a
|
||||
// redovisningsperiod, not a run, so when a corrected month holds two runs the
|
||||
// second one must still see, and be able to unlock, the draft that blocks it.
|
||||
const subState = submission?.status
|
||||
const awaitingSigning = subState === 'awaiting_signing'
|
||||
const underlagSubmitted = subState === 'underlag_submitted'
|
||||
const underlagRejected = subState === 'underlag_rejected'
|
||||
const isSigned = subState === 'signed' || !!agiSubmittedAt
|
||||
// RUN scope (`runSubmission`): the filing receipt. A correction is a complete
|
||||
// replacement declaration for the same period (same specifikationsnummer per
|
||||
// employee) filed on its own, so it gets its OWN kvittens. Reading the period
|
||||
// record raw here would render the correction as already filed and print the
|
||||
// superseded declaration's kvittensnummer on it.
|
||||
const runSubmission = resolveRunAgiSubmission(
|
||||
{ id: salaryRunId, agi_generated_at: agiGeneratedAt, agi_submitted_at: agiSubmittedAt },
|
||||
submission,
|
||||
)
|
||||
const isSigned = runSubmission?.status === 'signed' || !!agiSubmittedAt
|
||||
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
|
||||
// If the run's AGI was (re)generated AFTER this signing draft was created,
|
||||
// the locked underlag at Skatteverket reflects superseded figures and must
|
||||
@@ -874,7 +896,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
const forcedAdvanced = draftIsStale || underlagRejected
|
||||
const advancedOpen = showAdvanced || forcedAdvanced
|
||||
|
||||
const signedAtRaw = submission?.signeradTid ?? agiSubmittedAt ?? null
|
||||
const signedAtRaw = runSubmission?.signeradTid ?? agiSubmittedAt ?? null
|
||||
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
|
||||
|
||||
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
|
||||
@@ -917,9 +939,11 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Filed: the terminal state deserves more than a gray status row.
|
||||
Kvittensnummer + signature metadata come from the submission
|
||||
Kvittensnummer + signature metadata come from the run-scoped
|
||||
record; a run stamped only via agi_submitted_at (e.g. cron
|
||||
reconciliation with an evicted cache) still gets the card. */}
|
||||
reconciliation with an evicted cache, or an original whose cached
|
||||
receipt a later correction has replaced) still gets the card, just
|
||||
without a number: better than showing another declaration's. */}
|
||||
{isSigned && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -928,20 +952,20 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
<p className="text-sm font-medium">
|
||||
{t('success_card_title', { period: prettyPeriod })}
|
||||
</p>
|
||||
{submission?.kvittensnummer && (
|
||||
{runSubmission?.kvittensnummer && (
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('success_card_kvittens', { kvittens: submission.kvittensnummer })}
|
||||
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
|
||||
</p>
|
||||
)}
|
||||
{(submission?.signeradAv || signedAtText) && (
|
||||
{(runSubmission?.signeradAv || signedAtText) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{submission?.signeradAv
|
||||
{runSubmission?.signeradAv
|
||||
? signedAtText
|
||||
? t('success_card_signed_by_at', {
|
||||
name: submission.signeradAv,
|
||||
name: runSubmission.signeradAv,
|
||||
date: signedAtText,
|
||||
})
|
||||
: t('success_card_signed_by', { name: submission.signeradAv })
|
||||
: t('success_card_signed_by', { name: runSubmission.signeradAv })
|
||||
: t('success_card_signed_at', { date: signedAtText ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
@@ -998,8 +1022,8 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
<StatusRow
|
||||
ok={isSigned}
|
||||
okText={
|
||||
submission?.kvittensnummer
|
||||
? t('submitted_with_kvittens', { kvittens: submission.kvittensnummer })
|
||||
runSubmission?.kvittensnummer
|
||||
? t('submitted_with_kvittens', { kvittens: runSubmission.kvittensnummer })
|
||||
: agiSubmittedAt
|
||||
? t('submitted_at', { date: new Date(agiSubmittedAt).toLocaleString('sv-SE') })
|
||||
: t('submitted')
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { AlertTriangle, Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { downloadFile } from '@/lib/browser/download-file'
|
||||
import { failureDescription } from '@/lib/browser/action-failure'
|
||||
import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type PaymentFormat = 'bg_lb' | 'pain001'
|
||||
|
||||
@@ -51,6 +53,7 @@ export function PaymentFilePanel({
|
||||
onDownloaded,
|
||||
}: PaymentFilePanelProps) {
|
||||
const t = useTranslations('salary_payments')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const [format, setFormat] = useState<PaymentFormat>(defaultFormat)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
@@ -73,28 +76,36 @@ export function PaymentFilePanel({
|
||||
: `/api/salary/runs/${salaryRunId}/payment/pain001`
|
||||
|
||||
async function handleDownload() {
|
||||
// The button is disabled while a file is in flight; this guard closes the
|
||||
// double-click / Enter-repeat race before React has re-rendered it. Two
|
||||
// payment files for one salary run is not a cosmetic problem: each one is
|
||||
// payable on its own, so a user who uploads both to the bank pays the
|
||||
// month's wages twice.
|
||||
if (downloading) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const res = await fetch(endpoint)
|
||||
if (!res.ok) {
|
||||
const result = await res.json().catch(() => ({ error: t('download_failed_fallback') }))
|
||||
const ext = format === 'bg_lb' ? 'txt' : 'xml'
|
||||
// Bounded, and no file is written unless the server answered 2xx with a
|
||||
// complete body: an error envelope saved as lon_2026-04.xml is a file the
|
||||
// user would carry to the bank before discovering it pays nobody.
|
||||
const result = await downloadFile({
|
||||
url: endpoint,
|
||||
filename: `lon_${periodLabel}.${ext}`,
|
||||
locale,
|
||||
})
|
||||
// Exactly one toast per outcome. TOAST_LIMIT is 1, so a failure toast
|
||||
// followed by a success toast in the same tick would render only the last.
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('download_failed_title'),
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
description: failureDescription(result, {
|
||||
timeout: t('download_timeout'),
|
||||
network: t('download_network'),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const ext = format === 'bg_lb' ? 'txt' : 'xml'
|
||||
a.download = `lon_${periodLabel}.${ext}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast({ title: t('downloaded') })
|
||||
onDownloaded?.()
|
||||
} finally {
|
||||
|
||||
@@ -161,13 +161,24 @@ export function SalaryCalendar({
|
||||
requests.push(fetch(`/api/salary/employees/${employeeId}/worked-hours?from=${from}&to=${to}`))
|
||||
}
|
||||
const responses = await Promise.all(requests)
|
||||
const absJson = await responses[0]!.json()
|
||||
if (!responses[0]!.ok) throw new Error(absJson.error || t('error_load_absence'))
|
||||
setAbsences(absJson.data ?? [])
|
||||
// Map the parsed body plus the status, never `new Error(json.error)`:
|
||||
// the routes answer thrown errors with the canonical envelope
|
||||
// `{ error: { code, message } }`, and the Error constructor would
|
||||
// stringify that object to "[object Object]", discarding the route's
|
||||
// own Swedish reason.
|
||||
const absJson = await responses[0]!.json().catch(() => null)
|
||||
if (!responses[0]!.ok) {
|
||||
setError(getUserErrorMessage(absJson, { statusCode: responses[0]!.status }))
|
||||
return
|
||||
}
|
||||
setAbsences(absJson?.data ?? [])
|
||||
if (isHourly && responses[1]) {
|
||||
const wJson = await responses[1].json()
|
||||
if (!responses[1].ok) throw new Error(wJson.error || t('error_load_worked'))
|
||||
setWorked(wJson.data ?? [])
|
||||
const wJson = await responses[1].json().catch(() => null)
|
||||
if (!responses[1].ok) {
|
||||
setError(getUserErrorMessage(wJson, { statusCode: responses[1].status }))
|
||||
return
|
||||
}
|
||||
setWorked(wJson?.data ?? [])
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
|
||||
@@ -302,8 +313,9 @@ export function SalaryCalendar({
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!wRes.ok) {
|
||||
const j = await wRes.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_delete_worked_date', { date }))
|
||||
const j = await wRes.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: wRes.status }))
|
||||
return
|
||||
}
|
||||
}
|
||||
const aRes = await fetch(
|
||||
@@ -311,8 +323,9 @@ export function SalaryCalendar({
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!aRes.ok) {
|
||||
const j = await aRes.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_delete_absence_date', { date }))
|
||||
const j = await aRes.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: aRes.status }))
|
||||
return
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
@@ -621,8 +634,9 @@ function BulkWorkedDialog({
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_delete_date', { date }))
|
||||
const j = await res.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
}
|
||||
onSaved([])
|
||||
@@ -643,7 +657,10 @@ function BulkWorkedDialog({
|
||||
setConflicts(json.data?.conflicts ?? [])
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(json.error || t('error_save_hours'))
|
||||
if (!res.ok) {
|
||||
setError(getUserErrorMessage(json, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
onSaved([])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error'))
|
||||
@@ -782,8 +799,9 @@ function BulkAbsenceDialog({
|
||||
continue
|
||||
}
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_save_absence_date', { date }))
|
||||
const j = await res.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
}
|
||||
if (localConflicts.length > 0) {
|
||||
@@ -913,8 +931,9 @@ function DayInspectorDialog({
|
||||
try {
|
||||
const res = await fetch(`/api/salary/employees/${employeeId}/worked-hours?date=${date}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_delete_worked'))
|
||||
const j = await res.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
onClose()
|
||||
@@ -934,8 +953,9 @@ function DayInspectorDialog({
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j.error || t('error_delete_absence'))
|
||||
const j = await res.json().catch(() => null)
|
||||
setError(getUserErrorMessage(j, { statusCode: res.status }))
|
||||
return
|
||||
}
|
||||
onChanged()
|
||||
// Stay open if there's other content; close if this was the last entry.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { downloadFile } from '@/lib/browser/download-file'
|
||||
import { postAction } from '@/lib/browser/post-action'
|
||||
import { failureDescription } from '@/lib/browser/action-failure'
|
||||
import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
interface TaxPaymentPanelProps {
|
||||
@@ -35,6 +38,7 @@ export function TaxPaymentPanel({
|
||||
onChange,
|
||||
}: TaxPaymentPanelProps) {
|
||||
const t = useTranslations('salary_payments')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [marking, setMarking] = useState(false)
|
||||
@@ -53,45 +57,56 @@ export function TaxPaymentPanel({
|
||||
const totalAmount = Math.round((totalTax + totalAvgifter) * 100) / 100
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
// Both buttons are disabled while either is in flight; this guard closes the
|
||||
// double-click race before React has re-rendered them. A second file for the
|
||||
// same period is a second payable instruction to Skattekontot.
|
||||
if (downloading || marking) return
|
||||
setDownloading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/skatteverket/tax-payments/${period}/payment-file`)
|
||||
if (!res.ok) {
|
||||
const result = await res.json().catch(() => ({ error: t('tax_download_failed_fallback') }))
|
||||
// Bounded, and no file is written unless the server answered 2xx with a
|
||||
// complete body: an error envelope saved as bg_lb_skatt_2026-04.txt is a
|
||||
// file the user would upload to the bank before discovering it pays no tax.
|
||||
const result = await downloadFile({
|
||||
url: `/api/skatteverket/tax-payments/${period}/payment-file`,
|
||||
filename: `bg_lb_skatt_${period}.txt`,
|
||||
locale,
|
||||
})
|
||||
// Exactly one toast per outcome: TOAST_LIMIT is 1.
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('tax_download_failed_title'),
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
description: failureDescription(result, {
|
||||
timeout: t('download_timeout'),
|
||||
network: t('download_network'),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `bg_lb_skatt_${period}.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast({ title: t('tax_downloaded') })
|
||||
onChange?.()
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}, [period, toast, onChange, t])
|
||||
}, [period, toast, onChange, t, locale, downloading, marking])
|
||||
|
||||
const handleMarkPaid = useCallback(async () => {
|
||||
if (downloading || marking) return
|
||||
setMarking(true)
|
||||
try {
|
||||
const res = await fetch(`/api/skatteverket/tax-payments/${period}/mark-paid`, {
|
||||
method: 'POST',
|
||||
const result = await postAction({
|
||||
url: `/api/skatteverket/tax-payments/${period}/mark-paid`,
|
||||
locale,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const result = await res.json().catch(() => ({ error: t('tax_mark_paid_failed_fallback') }))
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('tax_mark_paid_failed_title'),
|
||||
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
|
||||
description: failureDescription(result, {
|
||||
// A timeout on a write is genuinely ambiguous: the update may have
|
||||
// landed. Say that instead of claiming it failed.
|
||||
timeout: t('tax_mark_paid_timeout'),
|
||||
network: t('tax_mark_paid_network'),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -101,7 +116,7 @@ export function TaxPaymentPanel({
|
||||
} finally {
|
||||
setMarking(false)
|
||||
}
|
||||
}, [period, toast, onChange, t])
|
||||
}, [period, toast, onChange, t, locale, downloading, marking])
|
||||
|
||||
if (totalAmount <= 0) return null
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Palmtree, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface BalanceRow {
|
||||
employee_vacation_balance_id: string
|
||||
@@ -96,11 +97,14 @@ export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dry_run: true }),
|
||||
})
|
||||
const body = await res.json()
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
if (res.ok && body) {
|
||||
setReport(body.data.report as CloseReport)
|
||||
} else {
|
||||
setPreviewError(body.error ?? t('vacation_close_preview_failed'))
|
||||
// Resolve via the parsed body plus the status: the route answers thrown
|
||||
// errors with the canonical envelope `{ error: { code, message } }`,
|
||||
// and rendering that object would crash React.
|
||||
setPreviewError(getErrorMessage(body, { statusCode: res.status }))
|
||||
}
|
||||
setPreviewing(false)
|
||||
}
|
||||
@@ -112,7 +116,7 @@ export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ book_adjustment: true }),
|
||||
})
|
||||
const body = await res.json()
|
||||
const body = await res.json().catch(() => null)
|
||||
if (res.ok) {
|
||||
toast({ title: t('vacation_close_done') })
|
||||
setDialogOpen(false)
|
||||
@@ -121,7 +125,7 @@ export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
|
||||
} else {
|
||||
toast({
|
||||
title: t('vacation_close_failed'),
|
||||
description: body.error,
|
||||
description: getErrorMessage(body, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,14 +17,16 @@ import {
|
||||
import { ChevronRight, FileDown, Loader2, Trash2 } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { Employee, SalaryRunEmployee } from '@/types'
|
||||
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
|
||||
import type { RunDetail } from './types'
|
||||
|
||||
type SreWithEmployee = SalaryRunEmployee & {
|
||||
employee?: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
personnummer: string
|
||||
// The run detail endpoint returns the masked display form under this key;
|
||||
// the encrypted `personnummer` column never reaches the client.
|
||||
personnummer_masked: string
|
||||
default_dimensions?: Record<string, string>
|
||||
}
|
||||
}
|
||||
@@ -33,7 +35,7 @@ interface RunEmployeesTableProps {
|
||||
run: RunDetail
|
||||
runId: string
|
||||
employees: SalaryRunEmployee[]
|
||||
availableEmployees: Employee[]
|
||||
availableEmployees: EmployeeMasked[]
|
||||
canWrite: boolean
|
||||
actionLoading: string | null
|
||||
dimensionsEnabled: boolean
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useId } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -19,7 +20,11 @@ import {
|
||||
import { RetentionNotice } from '@/components/ui/retention-notice'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
canDeleteAccount,
|
||||
type OwnedCompanyBlocker,
|
||||
} from '@/components/settings/account-deletion'
|
||||
import {
|
||||
SettingsDangerZone,
|
||||
SettingsRow,
|
||||
@@ -27,47 +32,76 @@ import {
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
|
||||
interface Blocker {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const t = useTranslations('settings_account_danger')
|
||||
const tRetention = useTranslations('retention_notice')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [blockers, setBlockers] = useState<Blocker[]>([])
|
||||
const [blockersLoading, setBlockersLoading] = useState(true)
|
||||
// null = the blocker list is not known: still loading, or the read failed
|
||||
// (loadError). "Cannot read the blockers" must mean "cannot permit
|
||||
// deletion", never "no blockers": canDeleteAccount() only unlocks the
|
||||
// button for a confirmed empty list.
|
||||
const [blockers, setBlockers] = useState<OwnedCompanyBlocker[] | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const errorId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!cancelled) setEmail(user?.email ?? null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/company?owned=true&archived=false')
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
if (!cancelled) setBlockers(body.data ?? [])
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setBlockers(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getUserErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// whose data is not a list is a failed read too. Neither may become a
|
||||
// fabricated "no blockers".
|
||||
const body = await res.json()
|
||||
if (cancelled) return
|
||||
if (Array.isArray(body?.data)) {
|
||||
setBlockers(body.data)
|
||||
} else {
|
||||
setBlockers(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setBlockers(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBlockersLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
async function handleDelete() {
|
||||
if (!email) return
|
||||
@@ -84,10 +118,22 @@ export function AccountDangerZone() {
|
||||
})
|
||||
|
||||
if (response.status === 409) {
|
||||
const body = await response.json()
|
||||
// Precondition tripped mid-flow: refresh the list and show inline.
|
||||
setBlockers(body.blockers ?? [])
|
||||
setError(body.error || t('delete_failed_blockers'))
|
||||
const body = await response.json().catch(() => null)
|
||||
// Precondition tripped mid-flow: refresh the list and show inline. A
|
||||
// 409 without a readable list still means blockers exist, so drop to
|
||||
// unknown (button stays locked) and re-read the authoritative list
|
||||
// instead of fabricating an empty one.
|
||||
if (Array.isArray(body?.blockers)) {
|
||||
setBlockers(body.blockers)
|
||||
} else {
|
||||
setBlockers(null)
|
||||
setReloadKey((k) => k + 1)
|
||||
}
|
||||
setError(
|
||||
typeof body?.error === 'string' && body.error
|
||||
? body.error
|
||||
: t('delete_failed_blockers'),
|
||||
)
|
||||
setIsDeleting(false)
|
||||
setShowDialog(false)
|
||||
return
|
||||
@@ -105,15 +151,14 @@ export function AccountDangerZone() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasBlockers = blockers.length > 0
|
||||
const canDelete = !hasBlockers && !blockersLoading
|
||||
const canDelete = canDeleteAccount(blockers)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDangerZone label={t('heading')}>
|
||||
{/* Owned companies block deletion: functional state, kept visible as
|
||||
rows (only the first row carries the label and the why-help). */}
|
||||
{hasBlockers &&
|
||||
{blockers !== null &&
|
||||
blockers.map((b, i) => (
|
||||
<SettingsRow
|
||||
key={b.id}
|
||||
@@ -137,6 +182,23 @@ export function AccountDangerZone() {
|
||||
help={<RetentionNotice variant="account" className="border-0 bg-transparent p-0" />}
|
||||
>
|
||||
<SettingsRowNote>{tRetention('account_title')}</SettingsRowNote>
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div id={errorId} role="status" aria-live="polite" className="min-w-0">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: t('blockers_load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${t('blockers_load_failed')} ${loadError.detail}`
|
||||
: t('blockers_load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
@@ -148,6 +210,7 @@ export function AccountDangerZone() {
|
||||
type="button"
|
||||
onClick={() => setShowDialog(true)}
|
||||
disabled={!canDelete}
|
||||
aria-describedby={loadError ? errorId : undefined}
|
||||
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('delete_button')}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
|
||||
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
|
||||
@@ -60,22 +63,20 @@ const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
|
||||
{ value: 'correction', label: 'Korrigeringar' },
|
||||
]
|
||||
|
||||
// The API returns errors either as a plain string (legacy/validation) or as
|
||||
// the canonical { code, message } envelope; extract something renderable.
|
||||
function apiErrorText(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message?: unknown }).message
|
||||
return typeof m === 'string' ? m : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function AgentMemoryPanel() {
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
|
||||
// null = the memory list is not known: still loading, or the read failed
|
||||
// (loadError). A failed read must never render the "Inga minnen ännu"
|
||||
// EmptyState: that is a claim about the assistant's memory, and it is only
|
||||
// true after a confirmed empty read.
|
||||
const [rows, setRows] = useState<AgentMemoryRow[] | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [includeDismissed, setIncludeDismissed] = useState(false)
|
||||
const [kindFilter, setKindFilter] = useState<'all' | Kind>('all')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -86,21 +87,58 @@ export function AgentMemoryPanel() {
|
||||
const [newKind, setNewKind] = useState<Kind>('fact')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const params = new URLSearchParams()
|
||||
if (includeDismissed) params.set('include_dismissed', 'true')
|
||||
if (kindFilter !== 'all') params.set('kind', kindFilter)
|
||||
const res = await fetch(`/api/agent/memory?${params.toString()}`)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte hämta minne', description: apiErrorText(json.error), variant: 'destructive' })
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
setRows(json.data as AgentMemoryRow[])
|
||||
}, [includeDismissed, kindFilter, toast])
|
||||
// The cancelled closure is the same idiom every sibling panel uses
|
||||
// (TeamPanel, AccountDangerZone): a response that lands after unmount, or
|
||||
// after a filter change superseded this load, must not setState. Without it
|
||||
// a slow "Alla" response could overwrite a newer filtered list.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
const params = new URLSearchParams()
|
||||
if (includeDismissed) params.set('include_dismissed', 'true')
|
||||
if (kindFilter !== 'all') params.set('kind', kindFilter)
|
||||
try {
|
||||
const res = await fetch(`/api/agent/memory?${params.toString()}`)
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const json = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setRows(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(json, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the list is a failed read too. Neither may become a
|
||||
// fabricated "Inga minnen ännu".
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
if (!Array.isArray(json?.data)) {
|
||||
setRows(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setRows(json.data as AgentMemoryRow[])
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setRows(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [includeDismissed, kindFilter, errorLocale, reloadKey])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const active = rows?.filter((r) => r.is_active).length ?? 0
|
||||
@@ -117,12 +155,30 @@ export function AgentMemoryPanel() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte uppdatera', description: apiErrorText(json.error), variant: 'destructive' })
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const json = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: 'Kunde inte uppdatera',
|
||||
description: getErrorMessage(json, { statusCode: res.status, locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
|
||||
} catch (err) {
|
||||
// A rejected fetch (offline, DNS failure) or a 200 whose body will not
|
||||
// parse never reaches the !res.ok arm above: without this toast the
|
||||
// click looks like a dead control rather than a save that did not land.
|
||||
// One toast per failed click, never two: TOAST_LIMIT is 1
|
||||
// (components/ui/use-toast.tsx) and a second would evict the first.
|
||||
toast({
|
||||
title: 'Kunde inte uppdatera',
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
@@ -137,16 +193,33 @@ export function AgentMemoryPanel() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newContent.trim(), kind: newKind }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte spara minne', description: apiErrorText(json.error), variant: 'destructive' })
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const json = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: 'Kunde inte spara minne',
|
||||
description: getErrorMessage(json, { statusCode: res.status, locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
|
||||
setNewContent('')
|
||||
setNewKind('fact')
|
||||
setShowAdd(false)
|
||||
toast({ title: 'Minne sparat' })
|
||||
} catch (err) {
|
||||
// A rejected fetch or a 200 whose body will not parse never reaches the
|
||||
// !res.ok arm above: the draft stays in the form and one toast says the
|
||||
// save did not land. One toast per outcome, never two: TOAST_LIMIT is 1
|
||||
// (components/ui/use-toast.tsx) and a second would evict the first.
|
||||
toast({
|
||||
title: 'Kunde inte spara minne',
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
@@ -262,7 +335,25 @@ export function AgentMemoryPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows === null && (
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0 px-1 pt-3">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: 'Försök igen', onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `Minnena kunde inte läsas in just nu. ${loadError.detail}`
|
||||
: 'Minnena kunde inte läsas in just nu.'}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rows === null && !loadError && (
|
||||
<div className="space-y-3 pt-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
@@ -11,6 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { SettingsGroup, SettingsRowNote } from '@/components/settings/SettingsRows'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type Tier = 'horizontal' | 'vertical' | 'modifier'
|
||||
|
||||
@@ -54,37 +57,71 @@ const PROSE =
|
||||
'[&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] ' +
|
||||
'[&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top'
|
||||
|
||||
// The API returns errors either as a plain string (legacy/validation) or as
|
||||
// the canonical { code, message } envelope; extract something renderable.
|
||||
function apiErrorText(error: unknown): string | undefined {
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message?: unknown }).message
|
||||
return typeof m === 'string' ? m : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function AgentSkillsPanel() {
|
||||
const { toast } = useToast()
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
|
||||
// null = the atom list is not known: still loading, or the read failed
|
||||
// (loadError). A failed read must never render the "Inga kunskapsområden
|
||||
// ännu" EmptyState: that is a claim about the assistant's composition, and
|
||||
// it is only true after a confirmed empty read.
|
||||
const [atoms, setAtoms] = useState<AtomMeta[] | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [bodies, setBodies] = useState<Record<string, string>>({})
|
||||
const [loadingBody, setLoadingBody] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch('/api/agent/skills')
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte hämta kunskap', description: apiErrorText(json.error), variant: 'destructive' })
|
||||
setAtoms([])
|
||||
return
|
||||
}
|
||||
setAtoms(json.data as AtomMeta[])
|
||||
}, [toast])
|
||||
// The cancelled closure is the same idiom every sibling panel uses
|
||||
// (TeamPanel, AccountDangerZone): a response that lands after unmount, or
|
||||
// after a newer load superseded this one, must not setState.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/agent/skills')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const json = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setAtoms(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(json, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the list is a failed read too. Neither may become a
|
||||
// fabricated "Inga kunskapsområden ännu".
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
if (!Array.isArray(json?.data)) {
|
||||
setAtoms(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setAtoms(json.data as AtomMeta[])
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAtoms(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<Tier, AtomMeta[]> = { horizontal: [], vertical: [], modifier: [] }
|
||||
@@ -108,12 +145,30 @@ export function AgentSkillsPanel() {
|
||||
setLoadingBody(atom.id)
|
||||
try {
|
||||
const res = await fetch(`/api/agent/skills?slug=${encodeURIComponent(atom.id)}`)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Kunde inte läsa kunskapen', description: apiErrorText(json.error), variant: 'destructive' })
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const json = await res.json().catch(() => null)
|
||||
toast({
|
||||
title: 'Kunde inte läsa kunskapen',
|
||||
description: getErrorMessage(json, { statusCode: res.status, locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setBodies((prev) => ({ ...prev, [atom.id]: (json.data?.body as string) ?? '' }))
|
||||
} catch (err) {
|
||||
// A rejected fetch or a 200 whose body will not parse never reaches the
|
||||
// !res.ok arm above: without this toast the expanded row just sits
|
||||
// empty with zero feedback. One toast per outcome, never two:
|
||||
// TOAST_LIMIT is 1 (components/ui/use-toast.tsx) and a second would
|
||||
// evict the first.
|
||||
toast({
|
||||
title: 'Kunde inte läsa kunskapen',
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoadingBody(null)
|
||||
}
|
||||
@@ -137,7 +192,25 @@ export function AgentSkillsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{atoms === null && (
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0 px-1">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: 'Försök igen', onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `Kunskapsområdena kunde inte läsas in just nu. ${loadError.detail}`
|
||||
: 'Kunskapsområdena kunde inte läsas in just nu.'}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atoms === null && !loadError && (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
|
||||
@@ -25,8 +25,10 @@ import {
|
||||
SettingsRow,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Loader2, Plus, Copy, Check, Trash2, Key, ChevronDown, AlertTriangle } from 'lucide-react'
|
||||
import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { STAGING_SCOPES } from '@/lib/auth/api-keys'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
@@ -168,37 +170,53 @@ interface ApiKey {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type CopyState = 'idle' | 'copied' | 'failed'
|
||||
|
||||
function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const t = useTranslations('settings_api_keys')
|
||||
const [state, setState] = useState<CopyState>('idle')
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// clipboard unavailable (insecure context): silently ignore
|
||||
// The write is the first await, so the click's user activation still holds.
|
||||
const result = await copyToClipboard(text)
|
||||
if (result !== 'copied') {
|
||||
// Never imply success. The block stays on screen and is select-all, so
|
||||
// the user can copy it by hand: with no clipboard there is no other way.
|
||||
setState('failed')
|
||||
return
|
||||
}
|
||||
setState('copied')
|
||||
setTimeout(() => setState('idle'), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<pre className="rounded-md bg-muted p-4 pr-12 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all">
|
||||
<pre className="select-all rounded-md bg-muted p-4 pr-12 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{text}
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className={cn(
|
||||
'absolute right-1.5 top-1.5 h-7 w-7 p-0 transition-opacity focus-visible:opacity-100',
|
||||
state === 'failed' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
aria-label={copyAriaLabel}
|
||||
>
|
||||
{copied ? (
|
||||
{state === 'copied' ? (
|
||||
<Check className="h-3.5 w-3.5 text-success" />
|
||||
) : state === 'failed' ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-attn" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{/* Live region is always mounted so the message is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite">
|
||||
{state === 'failed' && <AttnLine className="mt-1.5">{t('copy_failed')}</AttnLine>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,7 +285,6 @@ export function ApiKeysPanel() {
|
||||
const [newKeyMode, setNewKeyMode] = useState<'live' | 'test'>('live')
|
||||
const [newKeyScopes, setNewKeyScopes] = useState<Set<Scope>>(new Set(ALL_SCOPES))
|
||||
const [newKeyValue, setNewKeyValue] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Segregation-of-duties: a single key that both stages bookkeeping (any
|
||||
// STAGING_SCOPES member) AND can approve it (pending_operations:approve)
|
||||
@@ -367,12 +384,6 @@ export function ApiKeysPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(newKeyValue)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const mcpBase = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/extensions/ext/mcp-server/mcp`
|
||||
: '/api/extensions/ext/mcp-server/mcp'
|
||||
@@ -670,7 +681,6 @@ export function ApiKeysPanel() {
|
||||
<Dialog open={showKeyDialog} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewKeyValue('')
|
||||
setCopied(false)
|
||||
}
|
||||
setShowKeyDialog(open)
|
||||
}}>
|
||||
@@ -681,29 +691,11 @@ export function ApiKeysPanel() {
|
||||
{t('new_key_dialog_description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="relative">
|
||||
<code className="block rounded-md bg-muted p-4 pr-12 text-sm font-mono break-all">
|
||||
{newKeyValue}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('copy_aria')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyBlock text={newKeyValue} copyAriaLabel={t('copy_aria')} />
|
||||
<DialogFooter>
|
||||
<Button onClick={() => {
|
||||
setShowKeyDialog(false)
|
||||
setNewKeyValue('')
|
||||
setCopied(false)
|
||||
}}>
|
||||
{t('done')}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,7 +13,10 @@ import { Cloud, Download, Info, Loader2 } from 'lucide-react'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
getErrorMessage as getUserErrorMessage,
|
||||
type ErrorLocale,
|
||||
} from '@/lib/errors/get-error-message'
|
||||
|
||||
const CloudBackupPanel = getSettingsPanel('cloud-backup')
|
||||
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
|
||||
@@ -31,12 +35,22 @@ const LAST_DOWNLOAD_STORAGE_KEY = 'Accounted:last-backup-download'
|
||||
|
||||
export function BackupDownloadForm() {
|
||||
const t = useTranslations('settings_backup_download')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
|
||||
const [scope, setScope] = useState<Scope>('all')
|
||||
const [includeDocuments, setIncludeDocuments] = useState(true)
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
// null = the fiscal years are not known: still loading, or the read failed
|
||||
// (periodsError). A failed read must never render the confirmed-empty
|
||||
// "Inga räkenskapsår" option: the company usually has fiscal years, the
|
||||
// read just did not land. Scope "all" never touches this list, so the full
|
||||
// backup keeps working either way.
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[] | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [periodsError, setPeriodsError] = useState<{ detail: string | null } | null>(null)
|
||||
const [periodsReloadKey, setPeriodsReloadKey] = useState(0)
|
||||
const [selectedPeriodId, setSelectedPeriodId] = useState<string>('')
|
||||
const [estimate, setEstimate] = useState<EstimateResponse | null>(null)
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false)
|
||||
@@ -56,26 +70,50 @@ export function BackupDownloadForm() {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadPeriods() {
|
||||
setPeriodsError(null)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null,
|
||||
// and getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setPeriods(null)
|
||||
setPeriodsError({
|
||||
detail: sessionGone
|
||||
? getUserErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the list is a failed read too. Neither may become a
|
||||
// fabricated "Inga räkenskapsår".
|
||||
const body = await res.json()
|
||||
if (cancelled) return
|
||||
const sorted = (data || []) as FiscalPeriod[]
|
||||
if (!Array.isArray(body?.data)) {
|
||||
setPeriods(null)
|
||||
setPeriodsError({ detail: null })
|
||||
return
|
||||
}
|
||||
const sorted = body.data as FiscalPeriod[]
|
||||
setPeriods(sorted)
|
||||
if (sorted.length > 0 && !selectedPeriodId) {
|
||||
setSelectedPeriodId(sorted[0].id)
|
||||
if (sorted.length > 0) {
|
||||
setSelectedPeriodId((prev) => prev || sorted[0].id)
|
||||
}
|
||||
} catch {
|
||||
// silent: scope=all still works without periods loaded
|
||||
if (!cancelled) {
|
||||
setPeriods(null)
|
||||
setPeriodsError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
loadPeriods()
|
||||
void loadPeriods()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// Intentionally run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [periodsReloadKey, errorLocale])
|
||||
|
||||
const estimateUrl = useMemo(() => {
|
||||
const params = new URLSearchParams({ estimate: '1', scope })
|
||||
@@ -208,6 +246,28 @@ export function BackupDownloadForm() {
|
||||
recommendedLabel={t('recommended')}
|
||||
/>
|
||||
</div>
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. The full-history scope stays
|
||||
fully functional; the line only reports that per-period backup
|
||||
cannot be offered until the fiscal years can be read. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0">
|
||||
{periodsError && (
|
||||
<AttnLine
|
||||
action={
|
||||
periodsError.detail
|
||||
? undefined
|
||||
: {
|
||||
label: t('periods_load_retry'),
|
||||
onClick: () => setPeriodsReloadKey((k) => k + 1),
|
||||
}
|
||||
}
|
||||
>
|
||||
{periodsError.detail
|
||||
? `${t('periods_load_failed')} ${periodsError.detail}`
|
||||
: t('periods_load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === 'period' && (
|
||||
@@ -218,10 +278,14 @@ export function BackupDownloadForm() {
|
||||
value={selectedPeriodId}
|
||||
onChange={(e) => setSelectedPeriodId(e.target.value)}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
disabled={periods.length === 0}
|
||||
disabled={periods === null || periods.length === 0}
|
||||
>
|
||||
{periods.length === 0 && <option value="">{t('no_fiscal_years')}</option>}
|
||||
{periods.map((p) => (
|
||||
{/* The confirmed-empty option only after a confirmed empty
|
||||
read; an unknown list renders an empty disabled select. */}
|
||||
{periods !== null && periods.length === 0 && (
|
||||
<option value="">{t('no_fiscal_years')}</option>
|
||||
)}
|
||||
{(periods ?? []).map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.period_start}: {p.period_end}
|
||||
</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -18,11 +18,14 @@ import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Pencil, Copy } fr
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import { downloadFile } from '@/lib/browser/download-file'
|
||||
import type { ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
export function BookingTemplatesPanel() {
|
||||
const t = useTranslations('settings_booking_templates')
|
||||
const locale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
@@ -37,6 +40,7 @@ export function BookingTemplatesPanel() {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
// Shared dialog for editing a company/team template or customizing (duplicating)
|
||||
// a read-only system template. Mode is derived from is_system.
|
||||
const [activeTemplate, setActiveTemplate] = useState<BookingTemplateLibrary | null>(null)
|
||||
@@ -76,17 +80,30 @@ export function BookingTemplatesPanel() {
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
// The button is disabled while a run is in flight; this also covers the
|
||||
// keyboard/double-click race before React has re-rendered it.
|
||||
if (isExporting) return
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings/booking-templates/export')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'bokforingsmallar.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
toast({ title: t('toast_export_failed'), variant: 'destructive' })
|
||||
const result = await downloadFile({
|
||||
url: '/api/settings/booking-templates/export',
|
||||
filename: 'bokforingsmallar.json',
|
||||
locale,
|
||||
})
|
||||
// Success is silent on purpose: the saved file is the feedback. On
|
||||
// failure nothing was written to disk, so exactly one toast tells the
|
||||
// user why. Never two: TOAST_LIMIT is 1, so a second toast in the same
|
||||
// tick evicts the first and only the last one is ever rendered.
|
||||
if (!result.ok) {
|
||||
toast({
|
||||
title: t('toast_export_failed'),
|
||||
description:
|
||||
result.reason === 'timeout' ? t('toast_export_timeout') : result.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +159,14 @@ export function BookingTemplatesPanel() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isExporting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('export')}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
SettingsRowNote,
|
||||
SettingsSelect,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { parseCompanyMembersPayload } from '@/components/settings/members-payload'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { Loader2, Plus, Trash2, Mail } from 'lucide-react'
|
||||
|
||||
@@ -35,6 +38,7 @@ interface CompanyInvitation {
|
||||
|
||||
export function CompanyMembersSection() {
|
||||
const t = useTranslations('settings_company')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
|
||||
@@ -44,9 +48,17 @@ export function CompanyMembersSection() {
|
||||
member: t('members_role_member'),
|
||||
viewer: t('members_role_viewer'),
|
||||
}
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [members, setMembers] = useState<CompanyMemberItem[]>([])
|
||||
const [invitations, setInvitations] = useState<CompanyInvitation[]>([])
|
||||
// null = the roster is not known: still loading, or the read failed
|
||||
// (loadError). A failed read must never render an apparently member-less
|
||||
// company: a consultant looking at an empty list re-invites people who are
|
||||
// already members. EmptyState-like rendering is reserved for a confirmed
|
||||
// empty read.
|
||||
const [members, setMembers] = useState<CompanyMemberItem[] | null>(null)
|
||||
const [invitations, setInvitations] = useState<CompanyInvitation[] | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState<string>('viewer')
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
@@ -55,24 +67,51 @@ export function CompanyMembersSection() {
|
||||
const [canInvite, setCanInvite] = useState(false)
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/company/members')
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setMembers(data.data.members)
|
||||
setInvitations(data.data.invitations)
|
||||
setCanInvite(data.data.canInvite)
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setMembers(null)
|
||||
setInvitations(null)
|
||||
setCanInvite(false)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the roster lists is a failed read too. Neither may become a
|
||||
// fabricated empty member list.
|
||||
const parsed = parseCompanyMembersPayload<CompanyMemberItem, CompanyInvitation>(
|
||||
await res.json(),
|
||||
)
|
||||
if (parsed === null) {
|
||||
setMembers(null)
|
||||
setInvitations(null)
|
||||
setCanInvite(false)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setMembers(parsed.members)
|
||||
setInvitations(parsed.invitations)
|
||||
setCanInvite(parsed.canInvite)
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setMembers(null)
|
||||
setInvitations(null)
|
||||
setCanInvite(false)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}, [])
|
||||
}, [errorLocale])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers()
|
||||
}, [fetchMembers])
|
||||
void fetchMembers()
|
||||
}, [fetchMembers, reloadKey])
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -152,10 +191,31 @@ export function CompanyMembersSection() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
if (members === null || invitations === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<div>
|
||||
{/* Live region always mounted while the roster is unknown, so the
|
||||
failure is announced when it appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0 px-1">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: t('members_load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${t('members_load_failed')} ${loadError.detail}`
|
||||
: t('members_load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
{!loadError && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ export function DimensionsToggle() {
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// A rejected fetch (offline, DNS failure, aborted request) never reaches
|
||||
// the !res.ok arm above, and the switch is controlled by the settings
|
||||
// context, so it simply stays where it was: without this toast the click
|
||||
// looks like a dead control rather than a save that did not happen.
|
||||
// One toast per failed click, never two: TOAST_LIMIT is 1
|
||||
// (components/ui/use-toast.tsx) and a second would evict the first.
|
||||
toast({
|
||||
title: t('settings_save_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
||||
import { parseTeamMembersPayload } from '@/components/settings/members-payload'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
interface TeamMember {
|
||||
id: string
|
||||
@@ -16,9 +19,16 @@ interface TeamMember {
|
||||
|
||||
export function TeamPanel() {
|
||||
const t = useTranslations('settings_team_panel')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
// null = the roster is not known: still loading, or the read failed
|
||||
// (loadError). A failed read must never render an apparently member-less
|
||||
// team; the empty look is reserved for a confirmed empty read.
|
||||
const [members, setMembers] = useState<TeamMember[] | null>(null)
|
||||
const [teamName, setTeamName] = useState('')
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
const roleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
@@ -29,29 +39,78 @@ export function TeamPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/team/members')
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setMembers(data.data.members)
|
||||
if (data.data.teamName) setTeamName(data.data.teamName)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers()
|
||||
}, [fetchMembers])
|
||||
let cancelled = false
|
||||
|
||||
if (isLoading) {
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/team/members')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setMembers(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the roster list is a failed read too. Neither may become a
|
||||
// fabricated empty member list.
|
||||
const parsed = parseTeamMembersPayload<TeamMember>(await res.json())
|
||||
if (cancelled) return
|
||||
if (parsed === null) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
setMembers(parsed.members)
|
||||
if (parsed.teamName) setTeamName(parsed.teamName)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setMembers(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
if (members === null) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div>
|
||||
{/* Live region always mounted while the roster is unknown, so the
|
||||
failure is announced when it appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite" className="min-w-0 px-1">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: t('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${t('load_failed')} ${loadError.detail}`
|
||||
: t('load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
{!loadError && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canDeleteAccount } from '@/components/settings/account-deletion'
|
||||
|
||||
// The deletion gate on the most destructive settings surface. The server
|
||||
// enforces the same precondition with a 409, but the button must never claim
|
||||
// an unblocked path the client has not confirmed: "cannot read the blockers"
|
||||
// means "cannot permit deletion", never "no blockers".
|
||||
describe('canDeleteAccount', () => {
|
||||
it('permits deletion only after a confirmed empty blocker list', () => {
|
||||
expect(canDeleteAccount([])).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks while the user still owns companies', () => {
|
||||
expect(canDeleteAccount([{ id: 'c1', name: 'Testbolaget AB' }])).toBe(false)
|
||||
})
|
||||
|
||||
it('never permits deletion while the blocker list is unknown (loading or failed read)', () => {
|
||||
// The original bug: a failed GET /api/company left blockers = [] with
|
||||
// loading = false, so exactly this case rendered an unblocked
|
||||
// "Radera konto" button to a user who still owns companies.
|
||||
expect(canDeleteAccount(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
parseCompanyMembersPayload,
|
||||
parseTeamMembersPayload,
|
||||
} from '@/components/settings/members-payload'
|
||||
|
||||
// Read-side guards for the member rosters. A 200 whose body does not carry
|
||||
// the expected lists is a failed read, not an empty roster: the original bug
|
||||
// was `setMembers(data.data.members)` straight off `res.json()`, so a
|
||||
// wrong-shape 200 rendered an apparently member-less company, and a
|
||||
// consultant looking at that list re-invites people who are already members.
|
||||
describe('parseCompanyMembersPayload', () => {
|
||||
const valid = {
|
||||
data: {
|
||||
members: [{ id: 'm1', email: 'anna@example.se' }],
|
||||
invitations: [{ id: 'i1', email: 'bo@example.se' }],
|
||||
canInvite: true,
|
||||
},
|
||||
}
|
||||
|
||||
it('passes a well-formed payload through', () => {
|
||||
expect(parseCompanyMembersPayload(valid)).toEqual({
|
||||
members: [{ id: 'm1', email: 'anna@example.se' }],
|
||||
invitations: [{ id: 'i1', email: 'bo@example.se' }],
|
||||
canInvite: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a confirmed-empty roster (that is data, not a failure)', () => {
|
||||
expect(
|
||||
parseCompanyMembersPayload({ data: { members: [], invitations: [], canInvite: false } }),
|
||||
).toEqual({ members: [], invitations: [], canInvite: false })
|
||||
})
|
||||
|
||||
it('rejects a 200 with no data envelope instead of fabricating an empty roster', () => {
|
||||
expect(parseCompanyMembersPayload({})).toBeNull()
|
||||
expect(parseCompanyMembersPayload(null)).toBeNull()
|
||||
expect(parseCompanyMembersPayload('ok')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a 200 whose data lacks the lists', () => {
|
||||
expect(parseCompanyMembersPayload({ data: {} })).toBeNull()
|
||||
expect(parseCompanyMembersPayload({ data: { members: [] } })).toBeNull()
|
||||
expect(
|
||||
parseCompanyMembersPayload({ data: { members: 'oops', invitations: [] } }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('only grants canInvite on an explicit boolean true', () => {
|
||||
// A truthy non-boolean must not unlock the invite form.
|
||||
expect(
|
||||
parseCompanyMembersPayload({ data: { members: [], invitations: [], canInvite: 'yes' } }),
|
||||
).toEqual({ members: [], invitations: [], canInvite: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTeamMembersPayload', () => {
|
||||
it('passes a well-formed payload through', () => {
|
||||
expect(
|
||||
parseTeamMembersPayload({ data: { members: [{ id: 'm1' }], teamName: 'Byrån AB' } }),
|
||||
).toEqual({ members: [{ id: 'm1' }], teamName: 'Byrån AB' })
|
||||
})
|
||||
|
||||
it('accepts a confirmed-empty roster and a missing team name', () => {
|
||||
expect(parseTeamMembersPayload({ data: { members: [] } })).toEqual({
|
||||
members: [],
|
||||
teamName: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects wrong-shape 200s instead of fabricating an empty roster', () => {
|
||||
expect(parseTeamMembersPayload({})).toBeNull()
|
||||
expect(parseTeamMembersPayload(null)).toBeNull()
|
||||
expect(parseTeamMembersPayload({ data: {} })).toBeNull()
|
||||
expect(parseTeamMembersPayload({ data: { members: 'oops' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a non-string team name instead of rendering it', () => {
|
||||
expect(parseTeamMembersPayload({ data: { members: [], teamName: 42 } })).toEqual({
|
||||
members: [],
|
||||
teamName: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Deletion gate for the account danger zone (AccountDangerZone.tsx).
|
||||
*
|
||||
* `blockers === null` means the owned-companies read has not produced a
|
||||
* trustworthy answer: still loading, or the request failed. An unknown
|
||||
* blocker list must read as "cannot permit deletion", never as "no blockers".
|
||||
* The server enforces the same precondition with a 409, but the button on the
|
||||
* most destructive settings surface must not claim an unblocked path the
|
||||
* client has not confirmed.
|
||||
*/
|
||||
|
||||
export interface OwnedCompanyBlocker {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function canDeleteAccount(blockers: OwnedCompanyBlocker[] | null): boolean {
|
||||
return blockers !== null && blockers.length === 0
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Read-side payload guards for the member rosters
|
||||
* (CompanyMembersSection.tsx, TeamPanel.tsx).
|
||||
*
|
||||
* A 200 whose body does not carry the expected lists is a failed read, not an
|
||||
* empty roster: returning null forces the caller into its error state instead
|
||||
* of rendering an apparently member-less company or team. A consultant who
|
||||
* sees an empty list re-invites people who are already members, so "cannot
|
||||
* read the roster" must never render as "no members".
|
||||
*
|
||||
* The element types are the caller's own row interfaces; these guards verify
|
||||
* the envelope shape (lists are lists, flags are booleans), not the fields of
|
||||
* each row.
|
||||
*/
|
||||
|
||||
export interface CompanyMembersPayload<M, I> {
|
||||
members: M[]
|
||||
invitations: I[]
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
export function parseCompanyMembersPayload<M, I>(body: unknown): CompanyMembersPayload<M, I> | null {
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const data = (body as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const d = data as { members?: unknown; invitations?: unknown; canInvite?: unknown }
|
||||
if (!Array.isArray(d.members) || !Array.isArray(d.invitations)) return null
|
||||
return {
|
||||
members: d.members as M[],
|
||||
invitations: d.invitations as I[],
|
||||
// Only an explicit boolean true unlocks the invite form.
|
||||
canInvite: d.canInvite === true,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TeamMembersPayload<M> {
|
||||
members: M[]
|
||||
teamName: string | null
|
||||
}
|
||||
|
||||
export function parseTeamMembersPayload<M>(body: unknown): TeamMembersPayload<M> | null {
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const data = (body as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return null
|
||||
const d = data as { members?: unknown; teamName?: unknown }
|
||||
if (!Array.isArray(d.members)) return null
|
||||
return {
|
||||
members: d.members as M[],
|
||||
teamName: typeof d.teamName === 'string' && d.teamName.length > 0 ? d.teamName : null,
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
|
||||
@@ -75,26 +78,79 @@ export function AssistantSettingsContent() {
|
||||
// immediately instead of on next navigation.
|
||||
function FabVisibilityRow() {
|
||||
const t = useTranslations('settings_assistant')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
// null = not yet loaded (switch disabled meanwhile)
|
||||
// null = the value is not known: still loading, or the read failed (loadError).
|
||||
const [hideFab, setHideFab] = useState<boolean | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const errorId = useId()
|
||||
|
||||
// A settings row that cannot read its own value must not render one.
|
||||
// Defaulting to false on a failed GET drew a loaded-looking, interactive
|
||||
// switch in the "assistant button visible" position whatever the stored
|
||||
// preference was, and here that claim is contradicted on screen: the button
|
||||
// is server-rendered from the same column in the dashboard layout, so an
|
||||
// empty corner sat next to a switch insisting it was on.
|
||||
//
|
||||
// So the unknown value stays unknown: the switch keeps its disabled state and
|
||||
// one line says the value could not be read. 401/403 is the only case where
|
||||
// the reason changes what the user must do, so that message (from the same
|
||||
// status map the save path uses) replaces the retry instead of sitting next
|
||||
// to it; everything else is transient and the retry is the whole answer.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/user/preferences')
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/user/preferences')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setHideFab(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse is a failed read too: let it land in
|
||||
// the catch below rather than become a fabricated `false`.
|
||||
const body = await res.json()
|
||||
if (!cancelled) setHideFab(Boolean(body?.data?.hide_assistant_fab))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHideFab(false)
|
||||
})
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHideFab(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
// Rolling the switch back is necessary but not sufficient: a switch that
|
||||
// flips and flips again reads as a broken control, not as a refused save, so
|
||||
// the user retries the same click instead of signing in again (401 on an
|
||||
// expired session is by far the likeliest refusal here, and the status map
|
||||
// says exactly that). One toast per failed click, never two: TOAST_LIMIT is
|
||||
// 1 (components/ui/use-toast.tsx) and a second would evict the first.
|
||||
//
|
||||
// Only the fetch sits inside the try. A router.refresh() that threw must not
|
||||
// roll the switch back and claim the save failed, because by then the write
|
||||
// has landed.
|
||||
async function handleToggle(showFab: boolean) {
|
||||
const nextHide = !showFab
|
||||
const previous = hideFab
|
||||
@@ -106,24 +162,62 @@ function FabVisibilityRow() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hide_assistant_fab: nextHide }),
|
||||
})
|
||||
if (!res.ok) throw new Error('save failed')
|
||||
router.refresh()
|
||||
} catch {
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
setHideFab(previous)
|
||||
toast({
|
||||
title: t('fab_save_failed'),
|
||||
description: getErrorMessage(body, {
|
||||
statusCode: res.status,
|
||||
locale: errorLocale,
|
||||
}),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
setHideFab(previous)
|
||||
toast({
|
||||
title: t('fab_save_failed'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup>
|
||||
<SettingsRow label={t('fab_title')} help={t('fab_description')}>
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div id={errorId} role="status" aria-live="polite" className="min-w-0">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: t('fab_load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${t('fab_load_failed')} ${loadError.detail}`
|
||||
: t('fab_load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
<SettingsRowEnd>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
aria-describedby={loadError ? errorId : undefined}
|
||||
/>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Check } from 'lucide-react'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
@@ -72,37 +73,81 @@ function FeatureList({ heading, items }: { heading: string; items: string[] }) {
|
||||
export function BillingSettingsContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const tBilling = useTranslations('settings_billing')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
// null = the billing state is not known: still loading, or the read failed
|
||||
// (loadError). A failed GET must never render a fabricated non-paying,
|
||||
// unconfigured panel to a paying customer.
|
||||
const [view, setView] = useState<BillingView | null>(null)
|
||||
// detail === null: transient, so the line carries a retry. A detail sentence
|
||||
// means the user has to act (an expired session) and a retry cannot help.
|
||||
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetch('/api/billing/status')
|
||||
.then((r) => r.json())
|
||||
.then((d: { isPaying: boolean; configured: boolean; trialEndsAt: string | null; isDemo?: boolean }) => {
|
||||
|
||||
async function load() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const res = await fetch('/api/billing/status')
|
||||
if (!res.ok) {
|
||||
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
|
||||
// getErrorMessage falls back to the status map.
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!active) return
|
||||
const sessionGone = res.status === 401 || res.status === 403
|
||||
setView(null)
|
||||
setLoadError({
|
||||
detail: sessionGone
|
||||
? getErrorMessage(body, { statusCode: res.status, locale: errorLocale })
|
||||
: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A 200 whose body will not parse throws into the catch below; a 200
|
||||
// without the expected booleans is a failed read too. Neither may
|
||||
// become a fabricated view.
|
||||
const d = (await res.json()) as {
|
||||
isPaying?: unknown
|
||||
configured?: unknown
|
||||
trialEndsAt?: unknown
|
||||
isDemo?: unknown
|
||||
}
|
||||
if (!active) return
|
||||
if (typeof d?.isPaying !== 'boolean' || typeof d?.configured !== 'boolean') {
|
||||
setView(null)
|
||||
setLoadError({ detail: null })
|
||||
return
|
||||
}
|
||||
const trialEndsAt = typeof d.trialEndsAt === 'string' ? d.trialEndsAt : null
|
||||
// Compute time-derived state here (effect), not during render, to keep render pure.
|
||||
const msLeft = d.trialEndsAt ? new Date(d.trialEndsAt).getTime() - Date.now() : null
|
||||
const msLeft = trialEndsAt ? new Date(trialEndsAt).getTime() - Date.now() : null
|
||||
const daysLeft = msLeft !== null ? Math.max(0, Math.ceil(msLeft / 86_400_000)) : null
|
||||
const chargeDeferred = msLeft !== null && msLeft > DEFER_THRESHOLD_MS
|
||||
// Set by the checkout success redirect. Provisioning happens via the
|
||||
// Stripe webhook, so isPaying can lag the redirect by a few seconds.
|
||||
const paidJustNow = new URLSearchParams(window.location.search).get('success') === '1'
|
||||
setView({ ...d, daysLeft, chargeDeferred, paidJustNow, isDemo: d.isDemo ?? false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (active)
|
||||
setView({
|
||||
isPaying: false,
|
||||
configured: false,
|
||||
trialEndsAt: null,
|
||||
daysLeft: null,
|
||||
chargeDeferred: false,
|
||||
paidJustNow: false,
|
||||
isDemo: false,
|
||||
})
|
||||
})
|
||||
setView({
|
||||
isPaying: d.isPaying,
|
||||
configured: d.configured,
|
||||
trialEndsAt,
|
||||
daysLeft,
|
||||
chargeDeferred,
|
||||
paidJustNow,
|
||||
isDemo: d.isDemo === true,
|
||||
})
|
||||
} catch {
|
||||
if (active) {
|
||||
setView(null)
|
||||
setLoadError({ detail: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
}, [reloadKey, errorLocale])
|
||||
|
||||
const header = <SettingsSectionHeader title={tNav('billing')} intro={tIntro('billing')} />
|
||||
|
||||
@@ -110,10 +155,29 @@ export function BillingSettingsContent() {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="mt-6 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
{/* Live region always mounted so the failure is announced when it
|
||||
appears, not merely inserted. */}
|
||||
<div role="status" aria-live="polite" className="mt-3">
|
||||
{loadError && (
|
||||
<AttnLine
|
||||
action={
|
||||
loadError.detail
|
||||
? undefined
|
||||
: { label: tBilling('load_retry'), onClick: () => setReloadKey((k) => k + 1) }
|
||||
}
|
||||
>
|
||||
{loadError.detail
|
||||
? `${tBilling('load_failed')} ${loadError.detail}`
|
||||
: tBilling('load_failed')}
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
{!loadError && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,16 @@ export function SkattekontoMatchDialog({
|
||||
const json = await res.json()
|
||||
if (cancelled) return
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t('search_failed_default'))
|
||||
// Map the parsed body plus the status, never `new Error(json.error)`:
|
||||
// the Error constructor stringifies a non-string body field, and the
|
||||
// mapper would discard the route's own Swedish reason.
|
||||
toast({
|
||||
title: t('fetch_candidates_failed_title'),
|
||||
description: getUserErrorMessage(json, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setCandidates(json.data.candidates as MatchCandidate[])
|
||||
} catch (err) {
|
||||
@@ -112,7 +121,12 @@ export function SkattekontoMatchDialog({
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t('match_failed_default'))
|
||||
toast({
|
||||
title: t('match_failed_title'),
|
||||
description: getUserErrorMessage(json, { statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('match_success_title') })
|
||||
onMatched()
|
||||
|
||||
@@ -405,6 +405,7 @@ export default function NewSupplierInvoiceForm({
|
||||
const watchedItems = watch('items')
|
||||
const watchedSupplierId = watch('supplier_id')
|
||||
const watchedCurrency = watch('currency')
|
||||
const watchedExchangeRate = watch('exchange_rate')
|
||||
const watchedPaidPrivately = watch('paid_with_private_funds')
|
||||
const watchedReverseCharge = watch('reverse_charge')
|
||||
// Watched values used to decide whether the AI-filled indicator should
|
||||
@@ -862,6 +863,12 @@ export default function NewSupplierInvoiceForm({
|
||||
<AccrualPeriodControl
|
||||
direction="expense"
|
||||
amount={item.amount || 0}
|
||||
// Line amounts are in the invoice's currency; the K2 5 000 kr limit is
|
||||
// in SEK. The rate is the Riksbanken/manual one already on the form.
|
||||
// An empty field parses to NaN, which the control reads as "unknown"
|
||||
// and then hides the hint instead of comparing the wrong unit.
|
||||
currency={watchedCurrency}
|
||||
exchangeRate={parseFloat(watchedExchangeRate)}
|
||||
idPrefix={idPrefix}
|
||||
value={{
|
||||
start: item.accrual_period_start ?? '',
|
||||
|
||||
@@ -117,6 +117,23 @@ export default function BulkBookDialog({
|
||||
[transactions],
|
||||
)
|
||||
|
||||
// Currency homogeneity. A samlingsverifikation must be expressed in one
|
||||
// redovisningsvaluta (BFL 4 kap 6 §), so `txSumAbs` above is only a
|
||||
// meaningful number while every selected tx shares a currency: across
|
||||
// currencies it would add 100 EUR to 100 SEK as if they were one unit.
|
||||
// We therefore split the selection per currency and, when it spans more
|
||||
// than one, never render the combined scalar and never let confirm fire.
|
||||
// NULL currency is legacy for the column default 'SEK'.
|
||||
const currencyTotals = useMemo(() => {
|
||||
const byCurrency = new Map<string, number>()
|
||||
for (const tx of transactions) {
|
||||
const code = tx.currency ?? 'SEK'
|
||||
byCurrency.set(code, round2((byCurrency.get(code) ?? 0) + Math.abs(tx.amount)))
|
||||
}
|
||||
return Array.from(byCurrency.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [transactions])
|
||||
const isMixedCurrency = currencyTotals.length > 1
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null,
|
||||
[templates, selectedTemplateId],
|
||||
@@ -321,6 +338,7 @@ export default function BulkBookDialog({
|
||||
const allAccountsValid = previewLines.every((l) => /^\d{4}$/.test(l.account_number))
|
||||
const canConfirm =
|
||||
!submitting &&
|
||||
!isMixedCurrency &&
|
||||
tabReady &&
|
||||
description.trim().length > 0 &&
|
||||
previewLines.length >= 2 &&
|
||||
@@ -441,6 +459,58 @@ export default function BulkBookDialog({
|
||||
|
||||
if (transactions.length === 0) return null
|
||||
|
||||
// Mixed-currency dead end. This is deliberately NOT the advisory-guard
|
||||
// pattern with a "book anyway" escape: a verifikat spanning two
|
||||
// redovisningsvalutor is not representable at all, so there is no correct
|
||||
// outcome to bypass to. We show the honest per-currency subtotals instead
|
||||
// of one summed scalar, and the booking UI never renders, so the request
|
||||
// is blocked before it is built. The route and the RPC refuse the same
|
||||
// selection with BULK_BOOK_MIXED_CURRENCY.
|
||||
if (isMixedCurrency) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('title', { count: txCount, date: sharedDate ? formatDate(sharedDate) : '' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('mixed_currency_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t('mixed_currency_totals_label')}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{currencyTotals.map(([code, total]) => (
|
||||
<li
|
||||
key={code}
|
||||
className="flex items-center justify-between text-sm tabular-nums"
|
||||
>
|
||||
<span className="font-mono text-xs text-muted-foreground">{code}</span>
|
||||
<span>{formatCurrency(total, code)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{t('mixed_currency_blocked')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations, useLocale } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
@@ -160,14 +160,63 @@ export default function DuplicateBookingDialog({
|
||||
? t('dialog_duplicate_voucher_label', { label: candidate.voucher_label })
|
||||
: t('dialog_duplicate_voucher_generic')}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatCurrency(candidate.amount)}</span>
|
||||
{/* candidate.amount is ALWAYS SEK or null, never a foreign
|
||||
number (booking-duplicate-detection.ts): the explicit 'SEK'
|
||||
states that contract at the call site. When the sibling is
|
||||
foreign and carries no stored rate, amount is null and the
|
||||
honest figure is the sibling's own amount in its own
|
||||
currency, explicitly labelled. */}
|
||||
<span className="tabular-nums">
|
||||
{candidate.amount != null
|
||||
? formatCurrency(candidate.amount, 'SEK')
|
||||
: candidate.currency && candidate.amount_in_currency != null
|
||||
? formatCurrency(candidate.amount_in_currency, candidate.currency)
|
||||
: t('dialog_duplicate_amount_unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Verified FX sibling: the kr figure above is the sibling's own
|
||||
booked SEK value; show the foreign original beneath it so the
|
||||
user can recognise their EUR/USD line at a glance. */}
|
||||
{candidate.amount != null &&
|
||||
candidate.currency &&
|
||||
candidate.amount_in_currency != null && (
|
||||
<div className="text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatCurrency(candidate.amount_in_currency, candidate.currency)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(candidate.entry_date)}
|
||||
</div>
|
||||
{candidate.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">{candidate.description}</div>
|
||||
)}
|
||||
{/* Rateless foreign sibling: the amounts matched exactly in the
|
||||
shared currency, but no SEK value exists for it, so say that
|
||||
instead of printing an authoritative-looking kr figure.
|
||||
Gated on candidate.currency: the sentence names the currency,
|
||||
and interpolating an empty string renders broken Swedish
|
||||
("samma belopp i , men..."). A null-currency candidate with a
|
||||
null amount already shows dialog_duplicate_amount_unknown. */}
|
||||
{candidate.amount == null && candidate.currency && (
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('dialog_duplicate_sek_unavailable', { currency: candidate.currency })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Ledger-only candidate found for a bank line that has no SEK
|
||||
value: the kr figure above is the voucher leg's real amount,
|
||||
but it could not be compared against the transaction, so the
|
||||
match rests on date + account + direction alone. */}
|
||||
{candidate.amount != null && !candidate.amount_verified && (
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-warning-foreground flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('dialog_duplicate_amount_unverified')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { isInvoiceBookingRateMissing, previewedFxGainSek } from './invoice-match-fx'
|
||||
import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { BASAccount } from '@/types'
|
||||
@@ -16,9 +18,16 @@ interface DuplicateCandidate {
|
||||
voucher_label: string
|
||||
entry_date: string
|
||||
description: string | null
|
||||
/** The voucher leg's SEK debit: always kronor, never the bank line's own
|
||||
* (possibly foreign) amount. Render with an explicit 'SEK'. */
|
||||
amount: number
|
||||
bank_account_number: string
|
||||
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
|
||||
/** 'date_window_only' = the amount test never ran (no SEK value on the bank
|
||||
* line); the copy must not claim an amount match for that shape. */
|
||||
reason: 'exact_amount_same_date' | 'exact_amount_within_window' | 'date_window_only'
|
||||
/** False when the amounts were never compared (mirrors
|
||||
* lib/transactions/booking-duplicate-detection.ts). */
|
||||
amount_verified: boolean
|
||||
}
|
||||
|
||||
interface PreviewLine {
|
||||
@@ -108,6 +117,18 @@ function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview request that came back non-2xx. `code` is the structured error
|
||||
* code from the canonical envelope when the body carried one, `message` the
|
||||
* locale-resolved sentence. Both null for a transport failure (offline,
|
||||
* proxy error page), where the generic fallback copy is all we can honestly
|
||||
* say.
|
||||
*/
|
||||
interface PreviewFailure {
|
||||
code: string | null
|
||||
message: string | null
|
||||
}
|
||||
|
||||
export default function InvoiceMatchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -117,6 +138,7 @@ export default function InvoiceMatchDialog({
|
||||
onLinkToExisting,
|
||||
}: InvoiceMatchDialogProps) {
|
||||
const t = useTranslations('tx_invoice_match')
|
||||
const uiLocale = useLocale() === 'en' ? ('en' as const) : ('sv' as const)
|
||||
const isSupplierInvoice = !!transaction?.potential_supplier_invoice
|
||||
const isCustomerInvoice = !!transaction?.potential_invoice
|
||||
const transactionId = transaction?.id ?? null
|
||||
@@ -127,7 +149,7 @@ export default function InvoiceMatchDialog({
|
||||
const invoiceId = transaction?.potential_invoice?.id ?? null
|
||||
const supplierInvoiceId = transaction?.potential_supplier_invoice?.id ?? null
|
||||
const [preview, setPreview] = useState<MatchPreview | null>(null)
|
||||
const [previewFailed, setPreviewFailed] = useState(false)
|
||||
const [previewFailure, setPreviewFailure] = useState<PreviewFailure | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editLines, setEditLines] = useState<EditableLine[]>([])
|
||||
// Manual SEK-per-invoice-currency rate the user types when Riksbanken has
|
||||
@@ -161,7 +183,7 @@ export default function InvoiceMatchDialog({
|
||||
useEffect(() => {
|
||||
if (!open || !transactionId) {
|
||||
setPreview(null)
|
||||
setPreviewFailed(false)
|
||||
setPreviewFailure(null)
|
||||
setIsEditing(false)
|
||||
setEditLines([])
|
||||
setManualRate('')
|
||||
@@ -175,15 +197,35 @@ export default function InvoiceMatchDialog({
|
||||
: null
|
||||
if (!previewUrl) {
|
||||
setPreview(null)
|
||||
setPreviewFailed(false)
|
||||
setPreviewFailure(null)
|
||||
return
|
||||
}
|
||||
async function loadPreview() {
|
||||
setPreviewFailed(false)
|
||||
setPreviewFailure(null)
|
||||
try {
|
||||
const res = await fetch(previewUrl!)
|
||||
if (!res.ok) {
|
||||
if (!cancelled) setPreviewFailed(true)
|
||||
// The preview route builds its clearing lines with the same helper
|
||||
// the POST commits with, so it refuses in exactly the places the
|
||||
// commit would: a foreign invoice with no booking rate makes
|
||||
// buildInvoicePaymentClearingLines throw
|
||||
// MATCH_INVOICE_BOOKING_RATE_MISSING and this GET returns 400. Keep
|
||||
// the code and the Swedish sentence rather than collapsing every
|
||||
// failure into "could not preview, continue or cancel": that copy
|
||||
// invites an action the server has already decided to reject.
|
||||
let failure: PreviewFailure = { code: null, message: null }
|
||||
try {
|
||||
const body = (await res.json()) as { error?: { code?: unknown } }
|
||||
if (body?.error && typeof body.error === 'object') {
|
||||
failure = {
|
||||
code: typeof body.error.code === 'string' ? body.error.code : null,
|
||||
message: getErrorMessage(body, { locale: uiLocale }),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body (proxy/edge error page): generic copy is all we have.
|
||||
}
|
||||
if (!cancelled) setPreviewFailure(failure)
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as MatchPreview
|
||||
@@ -192,14 +234,14 @@ export default function InvoiceMatchDialog({
|
||||
setEditLines(data.lines.map(previewToEditable))
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setPreviewFailed(true)
|
||||
if (!cancelled) setPreviewFailure({ code: null, message: null })
|
||||
}
|
||||
}
|
||||
loadPreview()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId])
|
||||
}, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId, uiLocale])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) {
|
||||
@@ -248,6 +290,21 @@ export default function InvoiceMatchDialog({
|
||||
}
|
||||
}, [isEditing, editLines])
|
||||
|
||||
// Cross-currency settlement whose invoice carries no booked exchange rate.
|
||||
// The kursvinst/kursförlust is then UNDEFINED, not zero: the SEK value the
|
||||
// receivable was posted at is unknown, so nothing on this screen can honestly
|
||||
// state what the FX result of the settlement is, and the booking path refuses
|
||||
// to invent one. Third state, distinct from "no FX at all" (a SEK invoice)
|
||||
// and from "FX with a real computed result". See ./invoice-match-fx.ts.
|
||||
const invoiceCurrency = transaction?.potential_invoice?.currency ?? null
|
||||
const invoiceRateMissing = isInvoiceBookingRateMissing({
|
||||
transactionCurrency: transaction?.currency,
|
||||
invoiceCurrency,
|
||||
invoiceExchangeRate: transaction?.potential_invoice?.exchange_rate,
|
||||
previewEntryType: preview?.entry_type ?? null,
|
||||
previewErrorCode: previewFailure?.code ?? null,
|
||||
})
|
||||
|
||||
const handleConfirm = (opts?: { force?: boolean; expected_journal_entry_id?: string }) => {
|
||||
const linesPayload = isEditing && preview && editValidation.isValid
|
||||
? editLines.map((l) => {
|
||||
@@ -313,17 +370,30 @@ export default function InvoiceMatchDialog({
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-medium text-warning-foreground">{t('duplicate_title')}</p>
|
||||
{/* candidate.amount is the voucher leg's SEK debit
|
||||
(duplicate-payment-detection.ts), so it is formatted as
|
||||
SEK regardless of the transaction's currency: an
|
||||
11 500 kr leg must never print as "11 500,00 EUR".
|
||||
The unverified shape (date_window_only) uses copy that
|
||||
does NOT claim an amount match: the amounts were never
|
||||
compared (the bank line has no stored SEK value). */}
|
||||
<p className="text-muted-foreground">
|
||||
{candidate.reason === 'exact_amount_same_date'
|
||||
? t('duplicate_body_same_date', {
|
||||
{candidate.reason === 'date_window_only' || candidate.amount_verified === false
|
||||
? t('duplicate_body_unverified', {
|
||||
label: candidate.voucher_label,
|
||||
amount: formatCurrency(candidate.amount, transaction.currency),
|
||||
})
|
||||
: t('duplicate_body_window', {
|
||||
label: candidate.voucher_label,
|
||||
amount: formatCurrency(candidate.amount, transaction.currency),
|
||||
amount: formatCurrency(candidate.amount, 'SEK'),
|
||||
date: formatDate(candidate.entry_date),
|
||||
})}
|
||||
})
|
||||
: candidate.reason === 'exact_amount_same_date'
|
||||
? t('duplicate_body_same_date', {
|
||||
label: candidate.voucher_label,
|
||||
amount: formatCurrency(candidate.amount, 'SEK'),
|
||||
})
|
||||
: t('duplicate_body_window', {
|
||||
label: candidate.voucher_label,
|
||||
amount: formatCurrency(candidate.amount, 'SEK'),
|
||||
date: formatDate(candidate.entry_date),
|
||||
})}
|
||||
</p>
|
||||
{candidate.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@@ -505,20 +575,52 @@ export default function InvoiceMatchDialog({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Third FX state: the invoice is in a foreign currency and no
|
||||
booking rate was ever stored, so the SEK value of the 1510
|
||||
receivable is unknown and the kursvinst/kursförlust on
|
||||
settlement is not a computable number.
|
||||
buildInvoicePaymentClearingLines refuses to build the verifikat
|
||||
(MATCH_INVOICE_BOOKING_RATE_MISSING) on both the preview GET and
|
||||
the commit POST, so there is nothing to approve: say that up
|
||||
front instead of showing a confident zero. Rendered on its own
|
||||
rather than inside the Valutaomräkning card below, because in
|
||||
this state the preview 400s and that card never renders. */}
|
||||
{invoiceRateMissing && (
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 text-warning-foreground flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
{/* Untinted title, matching the sibling
|
||||
fx_rate_unavailable panel below: the ochre lives in the
|
||||
icon and the surface, not in the heading. */}
|
||||
<p className="font-medium">{t('fx_invoice_rate_missing_title')}</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('fx_invoice_rate_missing_description', {
|
||||
invoiceCurrency: invoiceCurrency ?? '',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valutaomräkning section: only renders when the preview
|
||||
route flagged a cross-currency settlement. Shows the
|
||||
Riksbanken rate + invoice-currency-equivalent of the bank
|
||||
payment + the projected post-payment invoice state. When
|
||||
the rate lookup failed, swaps in a manual-rate input so
|
||||
the user can type the rate from their bank statement and
|
||||
retry. */}
|
||||
route flagged a cross-currency settlement (a SEK invoice
|
||||
paid in SEK has no FX effect and renders nothing here).
|
||||
Shows the Riksbanken rate + invoice-currency-equivalent of
|
||||
the bank payment + the projected post-payment invoice state.
|
||||
When the payment-date rate lookup failed, swaps in a
|
||||
manual-rate input so the user can type the rate from their
|
||||
bank statement and retry. */}
|
||||
{preview?.fx_conversion?.required && (() => {
|
||||
const fx = preview.fx_conversion
|
||||
if (!fx?.required) return null
|
||||
const txAbs = transaction ? Math.abs(transaction.amount) : 0
|
||||
const invRemaining = transaction?.potential_invoice?.remaining_amount
|
||||
?? transaction?.potential_invoice?.total
|
||||
?? 0
|
||||
// fx_conversion is only produced by the customer-invoice preview
|
||||
// route. No invoice row means there is nothing honest to show:
|
||||
// render nothing rather than fall back to zeroed money.
|
||||
const inv = transaction.potential_invoice
|
||||
if (!inv) return null
|
||||
const invRemaining = inv.remaining_amount ?? inv.total
|
||||
|
||||
if ('error' in fx) {
|
||||
// Riksbanken unavailable: show manual rate input.
|
||||
@@ -560,13 +662,10 @@ export default function InvoiceMatchDialog({
|
||||
const paidInInvoice = fx.paid_in_invoice_currency
|
||||
const remainingAfter = Math.max(0, Math.round((invRemaining - paidInInvoice) * 100) / 100)
|
||||
const willBeFullyPaid = remainingAfter <= 0
|
||||
// FX gain/loss for the kursvinst/kursförlust note: bankSek -
|
||||
// arSek, where arSek = paidInInvoice × invoice.exchange_rate.
|
||||
// Positive number = the SEK we received exceeded the SEK
|
||||
// value of the debt reduction (kursvinst).
|
||||
const invoiceRate = transaction?.potential_invoice?.exchange_rate ?? 0
|
||||
const arSek = invoiceRate > 0 ? Math.round(paidInInvoice * invoiceRate * 100) / 100 : 0
|
||||
const fxGain = invoiceRate > 0 ? Math.round((txAbs - arSek) * 100) / 100 : 0
|
||||
// The kursvinst/kursförlust note is READ OFF the previewed
|
||||
// verifikat (3960 credit = vinst, 7960 debit = förlust) instead
|
||||
// of recomputed from the invoice here: see previewedFxGainSek.
|
||||
const fxGain = previewedFxGainSek(preview.lines)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
@@ -598,7 +697,7 @@ export default function InvoiceMatchDialog({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{willBeFullyPaid ? t('fx_status_paid') : t('fx_status_partially_paid')}
|
||||
{Math.abs(fxGain) > 0.005 && (
|
||||
{!invoiceRateMissing && Math.abs(fxGain) > 0.005 && (
|
||||
<>
|
||||
{' · '}
|
||||
{fxGain > 0
|
||||
@@ -612,8 +711,12 @@ export default function InvoiceMatchDialog({
|
||||
})()}
|
||||
|
||||
{/* Bookkeeping preview: editable. Read-only by default; user
|
||||
clicks "Redigera" to switch the rows to inputs. */}
|
||||
{(preview || previewFailed) && (
|
||||
clicks "Redigera" to switch the rows to inputs. Suppressed
|
||||
entirely when the invoice's missing booking rate is what
|
||||
blocked the preview: the ochre panel above already owns that
|
||||
story, and an empty "Bokföring" card with a second phrasing of
|
||||
the same refusal reads as two separate problems. */}
|
||||
{(preview || (previewFailure && !invoiceRateMissing)) && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium">{t('booking_title')}</p>
|
||||
@@ -641,8 +744,15 @@ export default function InvoiceMatchDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewFailed && !preview && (
|
||||
<p className="text-sm text-muted-foreground">{t('booking_unavailable')}</p>
|
||||
{/* Prefer the route's own structured message (resolved through
|
||||
getErrorMessage, so it follows the UI locale) over the
|
||||
generic "continue or cancel" copy: when the server named a
|
||||
reason the user can act on it, and "continue" is often not
|
||||
actually available. */}
|
||||
{previewFailure && !preview && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{previewFailure.message ?? t('booking_unavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && !isEditing && (
|
||||
@@ -806,7 +916,18 @@ export default function InvoiceMatchDialog({
|
||||
// paths pass through unaffected.
|
||||
(preview?.fx_conversion?.required === true &&
|
||||
'error' in preview.fx_conversion &&
|
||||
parseAmount(manualRate) <= 0)
|
||||
parseAmount(manualRate) <= 0) ||
|
||||
// Cross-currency invoice with no booked exchange rate: the FX
|
||||
// result of the settlement is uncomputable, so there is no
|
||||
// honest entry to approve and the POST would reject it with the
|
||||
// same MATCH_INVOICE_BOOKING_RATE_MISSING the preview already
|
||||
// returned. A hand-written entry is still allowed through: the
|
||||
// user has then supplied the numbers themselves rather than
|
||||
// approving a fabricated preview. (Edit mode requires a
|
||||
// successful preview to enter, so today this only relaxes the
|
||||
// guard in the defense-in-depth branch of
|
||||
// isInvoiceBookingRateMissing.)
|
||||
(invoiceRateMissing && !(isEditing && editValidation.isValid))
|
||||
}
|
||||
>
|
||||
{isConfirming ? t('confirming') : t('confirm_match')}
|
||||
|
||||
@@ -9,6 +9,11 @@ import { Search, FileText, Loader2 } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { Invoice, Customer } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import {
|
||||
DOMESTIC_CURRENCY,
|
||||
normalizeCurrency,
|
||||
rankInvoicesByAmountProximity,
|
||||
} from './invoice-candidate-ranking'
|
||||
|
||||
type OpenInvoice = Invoice & { customer?: Customer }
|
||||
|
||||
@@ -88,7 +93,6 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
}, [company, supabase])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
const filtered = !search
|
||||
? invoices
|
||||
: invoices.filter((inv) => {
|
||||
@@ -99,15 +103,16 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
)
|
||||
})
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
const remainA = a.remaining_amount ?? a.total
|
||||
const remainB = b.remaining_amount ?? b.total
|
||||
const diffA = Math.abs(remainA - txAmount)
|
||||
const diffB = Math.abs(remainB - txAmount)
|
||||
if (diffA !== diffB) return diffA - diffB
|
||||
return b.invoice_date.localeCompare(a.invoice_date)
|
||||
// Amount proximity is only meaningful between comparable amounts: ranking
|
||||
// a 1 000 EUR invoice as a perfect hit for a 1 000 SEK deposit put the
|
||||
// wrong row first. Foreign invoices stay in the list either way; see
|
||||
// ./invoice-candidate-ranking.
|
||||
return rankInvoicesByAmountProximity(filtered, {
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency,
|
||||
amountSek: transaction.amount_sek,
|
||||
})
|
||||
}, [invoices, search, transaction.amount])
|
||||
}, [invoices, search, transaction.amount, transaction.currency, transaction.amount_sek])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -140,16 +145,15 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-[55vh] overflow-y-auto pr-1">
|
||||
{sorted.map((invoice) => {
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
{sorted.map(({ invoice, proximity }) => {
|
||||
const remaining = invoice.remaining_amount ?? invoice.total
|
||||
const sameCurrency = transaction.currency === invoice.currency
|
||||
const exact = sameCurrency && Math.abs(remaining - txAmount) < 0.01
|
||||
const close =
|
||||
sameCurrency &&
|
||||
!exact &&
|
||||
txAmount > 0 &&
|
||||
Math.abs(remaining - txAmount) / txAmount < 0.01
|
||||
const { exact, close, candidateSek } = proximity
|
||||
const invoiceCurrency = normalizeCurrency(invoice.currency)
|
||||
// The currency earns a marker only when it deviates from the bank
|
||||
// row's: the same marker on every row would say nothing (design.md,
|
||||
// "chips mark exceptions"). It is what explains why a row is or is
|
||||
// not ranked as close.
|
||||
const foreignCurrency = proximity.basis !== 'same_currency'
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -182,6 +186,11 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
{t('status_partially_paid')}
|
||||
</span>
|
||||
)}
|
||||
{foreignCurrency && (
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{invoiceCurrency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{invoice.customer?.name || t('unknown_customer')} · {t('due_short', { date: formatDate(invoice.due_date) })}
|
||||
@@ -194,9 +203,14 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
|
||||
exact && 'text-success'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(remaining, invoice.currency)}
|
||||
{formatCurrency(remaining, invoiceCurrency)}
|
||||
</p>
|
||||
{exact && <p className="text-[10px] text-success">{t('exact_match')}</p>}
|
||||
{candidateSek != null && (
|
||||
<p className="text-[10px] text-muted-foreground tabular-nums">
|
||||
≈ {formatCurrency(candidateSek, DOMESTIC_CURRENCY)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { linkDocuments, formatFailedDocumentNames } from '@/lib/documents/link-documents'
|
||||
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
|
||||
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
@@ -63,6 +66,7 @@ export default function QuickReviewDialog({
|
||||
const t = useTranslations('tx_quick_review')
|
||||
const tCat = useTranslations('tx_categories')
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const [accountOverride, setAccountOverride] = useState(defaultAccount)
|
||||
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
@@ -201,27 +205,40 @@ export default function QuickReviewDialog({
|
||||
|
||||
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
|
||||
|
||||
// Link uploaded documents to the journal entry
|
||||
// Attach the uploaded underlag to the verifikat the booking just created.
|
||||
// BFL 5 kap 7 § requires the verifikation to reference its underlag and
|
||||
// BFL 7 kap requires that underlag to be archived with it; the verifikat
|
||||
// is already committed here, so a failed link can only be reported, not
|
||||
// undone. The parent's "Bokförd" toast must not be the last word when a
|
||||
// receipt never made it onto the books.
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
let linkFailCount = 0
|
||||
for (const file of filesToLink) {
|
||||
try {
|
||||
await fetch(`/api/documents/${file.id}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ journal_entry_id: journalEntryId }),
|
||||
})
|
||||
} catch {
|
||||
linkFailCount++
|
||||
}
|
||||
}
|
||||
if (linkFailCount > 0) {
|
||||
const targets = uploadedFiles
|
||||
.filter((f) => f.status === 'uploaded' && f.id)
|
||||
.map((f) => ({ documentId: f.id as string, fileName: f.fileName }))
|
||||
const { failed } = await linkDocuments(targets, journalEntryId)
|
||||
if (failed.length > 0) {
|
||||
toast({
|
||||
title: t('doc_link_failed_title'),
|
||||
description: t('doc_link_failed_description', { count: linkFailCount }),
|
||||
title: t('doc_link_failed_booked_title'),
|
||||
description: t('doc_link_failed_booked_description', {
|
||||
count: failed.length,
|
||||
files: formatFailedDocumentNames(failed),
|
||||
}),
|
||||
variant: 'destructive',
|
||||
action: (
|
||||
<ToastAction
|
||||
altText={t('doc_link_open_entry')}
|
||||
onClick={() => router.push(`/bookkeeping/${journalEntryId}`)}
|
||||
>
|
||||
{t('doc_link_open_entry')}
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
// By this point the parent has already closed the dialog (onConfirm
|
||||
// resolved before linkDocuments did), so this component's file state
|
||||
// is invisible either way: the toast above, with its open-entry
|
||||
// action, is the user's actual pointer to the underlag that did not
|
||||
// attach. The early return just skips the redundant cleanup below.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import { Search, FileText, Loader2 } from 'lucide-react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { SupplierInvoice, Supplier } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import {
|
||||
DOMESTIC_CURRENCY,
|
||||
normalizeCurrency,
|
||||
rankInvoicesByAmountProximity,
|
||||
} from './invoice-candidate-ranking'
|
||||
|
||||
type OpenSupplierInvoice = SupplierInvoice & { supplier?: Supplier }
|
||||
|
||||
@@ -83,7 +88,6 @@ export default function SupplierInvoicePicker({
|
||||
}, [company, supabase])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
const filtered = !search
|
||||
? invoices
|
||||
: invoices.filter((inv) => {
|
||||
@@ -94,15 +98,16 @@ export default function SupplierInvoicePicker({
|
||||
)
|
||||
})
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
const remainA = a.remaining_amount ?? a.total
|
||||
const remainB = b.remaining_amount ?? b.total
|
||||
const diffA = Math.abs(remainA - txAmount)
|
||||
const diffB = Math.abs(remainB - txAmount)
|
||||
if (diffA !== diffB) return diffA - diffB
|
||||
return b.invoice_date.localeCompare(a.invoice_date)
|
||||
// Amount proximity is only meaningful between comparable amounts: ranking
|
||||
// a 1 000 EUR invoice as a perfect hit for a 1 000 SEK payment put the
|
||||
// wrong row first. Foreign invoices stay in the list either way; see
|
||||
// ./invoice-candidate-ranking.
|
||||
return rankInvoicesByAmountProximity(filtered, {
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency,
|
||||
amountSek: transaction.amount_sek,
|
||||
})
|
||||
}, [invoices, search, transaction.amount])
|
||||
}, [invoices, search, transaction.amount, transaction.currency, transaction.amount_sek])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -135,16 +140,15 @@ export default function SupplierInvoicePicker({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-[55vh] overflow-y-auto pr-1">
|
||||
{sorted.map((invoice) => {
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
{sorted.map(({ invoice, proximity }) => {
|
||||
const remaining = invoice.remaining_amount ?? invoice.total
|
||||
const sameCurrency = transaction.currency === invoice.currency
|
||||
const exact = sameCurrency && Math.abs(remaining - txAmount) < 0.01
|
||||
const close =
|
||||
sameCurrency &&
|
||||
!exact &&
|
||||
txAmount > 0 &&
|
||||
Math.abs(remaining - txAmount) / txAmount < 0.01
|
||||
const { exact, close, candidateSek } = proximity
|
||||
const invoiceCurrency = normalizeCurrency(invoice.currency)
|
||||
// The currency earns a marker only when it deviates from the bank
|
||||
// row's: the same marker on every row would say nothing (design.md,
|
||||
// "chips mark exceptions"). It is what explains why a row is or is
|
||||
// not ranked as close.
|
||||
const foreignCurrency = proximity.basis !== 'same_currency'
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -177,6 +181,11 @@ export default function SupplierInvoicePicker({
|
||||
Delbetald
|
||||
</span>
|
||||
)}
|
||||
{foreignCurrency && (
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{invoiceCurrency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{invoice.supplier?.name || 'Okänd leverantör'} · Förfaller{' '}
|
||||
@@ -190,9 +199,14 @@ export default function SupplierInvoicePicker({
|
||||
exact && 'text-success'
|
||||
)}
|
||||
>
|
||||
{formatCurrency(remaining, invoice.currency)}
|
||||
{formatCurrency(remaining, invoiceCurrency)}
|
||||
</p>
|
||||
{exact && <p className="text-[10px] text-success">Exakt match</p>}
|
||||
{candidateSek != null && (
|
||||
<p className="text-[10px] text-muted-foreground tabular-nums">
|
||||
≈ {formatCurrency(candidateSek, DOMESTIC_CURRENCY)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
compareAmountProximity,
|
||||
normalizeCurrency,
|
||||
rankInvoicesByAmountProximity,
|
||||
type ProximityTarget,
|
||||
type RankableInvoice,
|
||||
} from '../invoice-candidate-ranking'
|
||||
|
||||
/**
|
||||
* Ranking rule of the two manual invoice pickers (InvoicePicker,
|
||||
* SupplierInvoicePicker).
|
||||
*
|
||||
* The bug: both sorted by `Math.abs(remaining - |tx.amount|)` with no regard
|
||||
* for currency. A 1 000 EUR invoice therefore ranked as a perfect match for a
|
||||
* 1 000 SEK bank row and sorted above the genuine 1 000 SEK invoice: the same
|
||||
* number, roughly eleven times the money.
|
||||
*
|
||||
* The fix is a RANKING fix, not a filter. Every open invoice stays in the list
|
||||
* (the user must still be able to settle a foreign invoice by hand); rows only
|
||||
* move. Several tests below assert exactly that.
|
||||
*
|
||||
* The rule lives in ../invoice-candidate-ranking.ts precisely so it can be
|
||||
* tested: this repo runs Vitest in the `node` environment and never renders
|
||||
* components, so logic embedded in JSX is unverifiable by construction. The
|
||||
* final block asserts the two things a pure function cannot: that both pickers
|
||||
* actually call the helper, and that they hand it the currency fields. A guard
|
||||
* fed `undefined` and defaulted to SEK is silently inert.
|
||||
*/
|
||||
|
||||
interface Fixture extends RankableInvoice {
|
||||
id: string
|
||||
}
|
||||
|
||||
const invoice = (over: Partial<Fixture> & { id: string }): Fixture => ({
|
||||
remaining_amount: 1000,
|
||||
total: 1000,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
invoice_date: '2026-07-01',
|
||||
...over,
|
||||
})
|
||||
|
||||
const order = (ranked: { invoice: Fixture }[]) => ranked.map((r) => r.invoice.id)
|
||||
|
||||
/** Rate the reported EUR invoices were booked at. */
|
||||
const EUR_RATE = 11.4967
|
||||
|
||||
describe('rankInvoicesByAmountProximity', () => {
|
||||
describe('same-currency ranking is unchanged', () => {
|
||||
const sekDeposit: ProximityTarget = { amount: -12500, currency: 'SEK', amountSek: null }
|
||||
|
||||
it('still puts the closest SEK amount first', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'far', remaining_amount: 50000, total: 50000 }),
|
||||
invoice({ id: 'near', remaining_amount: 12600, total: 12600 }),
|
||||
invoice({ id: 'exact', remaining_amount: 12500, total: 12500 }),
|
||||
],
|
||||
sekDeposit,
|
||||
)
|
||||
expect(order(ranked)).toEqual(['exact', 'near', 'far'])
|
||||
expect(ranked[0].proximity).toMatchObject({ basis: 'same_currency', diff: 0, exact: true })
|
||||
})
|
||||
|
||||
it('still breaks an amount tie with the newest invoice first', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'older', remaining_amount: 12500, total: 12500, invoice_date: '2026-01-05' }),
|
||||
invoice({ id: 'newer', remaining_amount: 12500, total: 12500, invoice_date: '2026-06-30' }),
|
||||
],
|
||||
sekDeposit,
|
||||
)
|
||||
expect(order(ranked)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('still ignores the sign of the bank amount', () => {
|
||||
// Supplier payments arrive negative, customer receipts positive; the
|
||||
// pickers rank both against the same outstanding amount.
|
||||
const outgoing = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'a', remaining_amount: 12500, total: 12500 })],
|
||||
{ amount: -12500, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
const incoming = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'a', remaining_amount: 12500, total: 12500 })],
|
||||
{ amount: 12500, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(outgoing[0].proximity.exact).toBe(true)
|
||||
expect(incoming[0].proximity.exact).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the reported bug', () => {
|
||||
it('does not let a 1 000 EUR invoice out-rank a genuine 1 000 SEK match', () => {
|
||||
// The whole finding. The EUR row is listed first on input so that a
|
||||
// ranking which ignores currency (or a no-op sort) leaves it on top.
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'eur-1000', currency: 'EUR', exchange_rate: EUR_RATE }),
|
||||
invoice({ id: 'sek-1000' }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['sek-1000', 'eur-1000'])
|
||||
})
|
||||
|
||||
it('measures the EUR row against its real SEK value, not its face number', () => {
|
||||
const [, eur] = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'sek-1000' }),
|
||||
invoice({ id: 'eur-1000', currency: 'EUR', exchange_rate: EUR_RATE }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(eur.proximity).toMatchObject({
|
||||
basis: 'converted_sek',
|
||||
diff: 10496.7,
|
||||
diffCurrency: 'SEK',
|
||||
candidateSek: 11496.7,
|
||||
exact: false,
|
||||
close: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('foreign invoices that do have a comparable value', () => {
|
||||
it('ranks a EUR invoice first when its SEK value is what the bank moved', () => {
|
||||
// The reason cross-currency matching exists: an 11 496,70 kr deposit
|
||||
// settling a 1 000 EUR invoice. Burying it under every wildly-off SEK
|
||||
// invoice would trade one bad ranking for another.
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'sek-50000', remaining_amount: 50000, total: 50000 }),
|
||||
invoice({ id: 'sek-11000', remaining_amount: 11000, total: 11000 }),
|
||||
invoice({ id: 'eur-1000', currency: 'EUR', exchange_rate: EUR_RATE }),
|
||||
],
|
||||
{ amount: 11496.7, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['eur-1000', 'sek-11000', 'sek-50000'])
|
||||
expect(ranked[0].proximity.diff).toBe(0)
|
||||
})
|
||||
|
||||
it('never calls a converted figure an exact match', () => {
|
||||
// It values the invoice at its own booking rate, not at what the bank
|
||||
// actually moved. Agreeing to the öre is a coincidence, not a
|
||||
// confirmation, so the row ranks but never claims.
|
||||
const [top] = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'eur-1000', currency: 'EUR', exchange_rate: EUR_RATE })],
|
||||
{ amount: 11496.7, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(top.proximity.diff).toBe(0)
|
||||
expect(top.proximity.exact).toBe(false)
|
||||
expect(top.proximity.close).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers an exact same-currency hit over an equally close FX estimate', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'sek-11496', remaining_amount: 11496.7, total: 11496.7 }),
|
||||
invoice({ id: 'eur-1000', currency: 'EUR', exchange_rate: EUR_RATE }),
|
||||
],
|
||||
{ amount: -1000, currency: 'EUR', amountSek: -11496.7 },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['eur-1000', 'sek-11496'])
|
||||
expect(ranked[0].proximity).toMatchObject({ basis: 'same_currency', exact: true })
|
||||
expect(ranked[1].proximity).toMatchObject({ basis: 'converted_sek', diff: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('foreign invoices with no comparable value', () => {
|
||||
it('still lists an unconvertible invoice, but never on top', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'eur-no-rate', currency: 'EUR', exchange_rate: null }),
|
||||
invoice({ id: 'sek-far', remaining_amount: 90000, total: 90000 }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['sek-far', 'eur-no-rate'])
|
||||
// Listed, not filtered: the user must still be able to pick it by hand.
|
||||
expect(ranked).toHaveLength(2)
|
||||
expect(ranked[1].proximity).toMatchObject({
|
||||
basis: 'incomparable',
|
||||
diff: null,
|
||||
diffCurrency: null,
|
||||
exact: false,
|
||||
close: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a rate outside the shared 0 < rate < 100000 bound as no rate', () => {
|
||||
for (const exchange_rate of [0, -1, 100000, Number.NaN]) {
|
||||
const [only] = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'eur', currency: 'EUR', exchange_rate })],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(only.proximity.basis).toBe('incomparable')
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot compare a foreign bank row that carries no amount_sek', () => {
|
||||
const [only] = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'sek-1000' })],
|
||||
{ amount: -1000, currency: 'NOK', amountSek: null },
|
||||
)
|
||||
expect(only.proximity.basis).toBe('incomparable')
|
||||
})
|
||||
|
||||
it('orders several unconvertible rows newest first', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'older', currency: 'EUR', exchange_rate: null, invoice_date: '2026-02-01' }),
|
||||
invoice({ id: 'newer', currency: 'USD', exchange_rate: null, invoice_date: '2026-05-01' }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['newer', 'older'])
|
||||
})
|
||||
|
||||
it('keeps every candidate in the list whatever the currency mix', () => {
|
||||
const candidates = [
|
||||
invoice({ id: 'sek' }),
|
||||
invoice({ id: 'eur-rate', currency: 'EUR', exchange_rate: EUR_RATE }),
|
||||
invoice({ id: 'usd-no-rate', currency: 'USD', exchange_rate: null }),
|
||||
invoice({ id: 'nok-no-rate', currency: 'NOK', exchange_rate: null }),
|
||||
]
|
||||
const ranked = rankInvoicesByAmountProximity(candidates, {
|
||||
amount: -1000,
|
||||
currency: 'SEK',
|
||||
amountSek: null,
|
||||
})
|
||||
expect(order(ranked).sort()).toEqual(candidates.map((c) => c.id).sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('NULL currency is SEK', () => {
|
||||
it('treats a null invoice currency as a domestic row', () => {
|
||||
// `invoices.currency` is `text default 'SEK'` with no NOT NULL. A plain
|
||||
// `===` would read NULL as "some other currency" and demote an ordinary
|
||||
// SEK invoice into the unranked tier.
|
||||
const [only] = rankInvoicesByAmountProximity(
|
||||
[invoice({ id: 'null-currency', currency: null })],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(only.proximity).toMatchObject({ basis: 'same_currency', exact: true })
|
||||
})
|
||||
|
||||
it('treats a null transaction currency as a domestic row', () => {
|
||||
// `transactions.currency` is nullable too.
|
||||
const [only] = rankInvoicesByAmountProximity([invoice({ id: 'sek' })], {
|
||||
amount: -1000,
|
||||
currency: null,
|
||||
amountSek: null,
|
||||
})
|
||||
expect(only.proximity).toMatchObject({ basis: 'same_currency', exact: true })
|
||||
})
|
||||
|
||||
it('ranks a null-currency row exactly like an explicit SEK row', () => {
|
||||
const explicit = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'far', remaining_amount: 9000, total: 9000, currency: 'SEK' }),
|
||||
invoice({ id: 'hit', currency: 'SEK' }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
const nullish = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'far', remaining_amount: 9000, total: 9000, currency: null }),
|
||||
invoice({ id: 'hit', currency: null }),
|
||||
],
|
||||
{ amount: -1000, currency: null, amountSek: null },
|
||||
)
|
||||
expect(order(nullish)).toEqual(order(explicit))
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to `total` when remaining_amount is missing', () => {
|
||||
const ranked = rankInvoicesByAmountProximity(
|
||||
[
|
||||
invoice({ id: 'far', remaining_amount: null, total: 40000 }),
|
||||
invoice({ id: 'hit', remaining_amount: null, total: 1000 }),
|
||||
],
|
||||
{ amount: -1000, currency: 'SEK', amountSek: null },
|
||||
)
|
||||
expect(order(ranked)).toEqual(['hit', 'far'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareAmountProximity', () => {
|
||||
const sek = (amount: number): ProximityTarget => ({
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
amountSek: null,
|
||||
})
|
||||
|
||||
it('flags a sub-öre difference as exact', () => {
|
||||
const p = compareAmountProximity(sek(-12500), { amount: 12500.004, currency: 'SEK' })
|
||||
expect(p.exact).toBe(true)
|
||||
})
|
||||
|
||||
it('flags a sub-percent difference as close but not exact', () => {
|
||||
const p = compareAmountProximity(sek(-12500), { amount: 12550, currency: 'SEK' })
|
||||
expect(p).toMatchObject({ basis: 'same_currency', exact: false, close: true, diff: 50 })
|
||||
})
|
||||
|
||||
it('flags a difference beyond one percent as neither', () => {
|
||||
const p = compareAmountProximity(sek(-12500), { amount: 13000, currency: 'SEK' })
|
||||
expect(p).toMatchObject({ exact: false, close: false, diff: 500 })
|
||||
})
|
||||
|
||||
it('does not divide by a zero-amount bank row', () => {
|
||||
const p = compareAmountProximity(sek(0), { amount: 1000, currency: 'SEK' })
|
||||
expect(p.close).toBe(false)
|
||||
expect(Number.isFinite(p.diff!)).toBe(true)
|
||||
})
|
||||
|
||||
it('rounds the converted value to whole öre', () => {
|
||||
// Money math per CLAUDE.md: Math.round(x * 100) / 100, never toFixed.
|
||||
const p = compareAmountProximity(sek(-1000), {
|
||||
amount: 33.333,
|
||||
currency: 'EUR',
|
||||
exchangeRate: 3,
|
||||
})
|
||||
expect(p.candidateSek).toBe(100)
|
||||
})
|
||||
|
||||
it('reports no conversion for a SEK candidate', () => {
|
||||
// candidateSek non-null means "a conversion was performed", which is the
|
||||
// only case the row shows a SEK equivalent for.
|
||||
const p = compareAmountProximity(
|
||||
{ amount: -1000, currency: 'EUR', amountSek: -11496.7 },
|
||||
{ amount: 11496.7, currency: 'SEK' },
|
||||
)
|
||||
expect(p.basis).toBe('converted_sek')
|
||||
expect(p.candidateSek).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the candidate SEK value even when the bank side is unconvertible', () => {
|
||||
const p = compareAmountProximity(
|
||||
{ amount: -1000, currency: 'NOK', amountSek: null },
|
||||
{ amount: 1000, currency: 'EUR', exchangeRate: EUR_RATE },
|
||||
)
|
||||
expect(p.basis).toBe('incomparable')
|
||||
expect(p.candidateSek).toBe(11496.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeCurrency', () => {
|
||||
it('maps null, undefined and blank to SEK', () => {
|
||||
for (const value of [null, undefined, '', ' ']) {
|
||||
expect(normalizeCurrency(value)).toBe('SEK')
|
||||
}
|
||||
})
|
||||
|
||||
it('upper-cases and trims a real code', () => {
|
||||
expect(normalizeCurrency(' eur ')).toBe('EUR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the pickers the helper serves', () => {
|
||||
const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', file), 'utf8')
|
||||
|
||||
for (const file of ['InvoicePicker.tsx', 'SupplierInvoicePicker.tsx']) {
|
||||
describe(file, () => {
|
||||
const SRC = read(file)
|
||||
|
||||
it('ranks through the shared helper', () => {
|
||||
expect(SRC).toContain('rankInvoicesByAmountProximity')
|
||||
})
|
||||
|
||||
it('no longer carries the currency-blind comparator', () => {
|
||||
// The literal shape of the bug. A pure function cannot see this, and a
|
||||
// reviewer reintroducing it would otherwise get a green suite: every
|
||||
// test above would still pass while the picker sorted on its own
|
||||
// currency-blind diff.
|
||||
expect(SRC).not.toMatch(/const remain[AB]\b/)
|
||||
expect(SRC).not.toMatch(/Math\.abs\(remain[AB]\s*-/)
|
||||
})
|
||||
|
||||
it('actually hands the helper the transaction currency', () => {
|
||||
// A guard reading `undefined` and defaulting to SEK is silently inert:
|
||||
// every foreign invoice would look domestic and the bug would survive
|
||||
// behind a green suite.
|
||||
expect(SRC).toMatch(/currency:\s*transaction\.currency/)
|
||||
expect(SRC).toMatch(/amountSek:\s*transaction\.amount_sek/)
|
||||
})
|
||||
|
||||
it('shows the deviating currency on the row', () => {
|
||||
expect(SRC).toContain('foreignCurrency')
|
||||
expect(SRC).toContain('invoiceCurrency')
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* The duplicate-payment warning panel of the invoice-match dialog.
|
||||
*
|
||||
* Two bugs pinned here:
|
||||
*
|
||||
* 1. `candidate.amount` is the voucher leg's SEK debit
|
||||
* (lib/invoices/duplicate-payment-detection.ts), but the dialog formatted
|
||||
* it in the TRANSACTION's currency: an 11 500 kr leg on a EUR bank line
|
||||
* printed as "11 500,00 EUR".
|
||||
* 2. When the bank line had no stored SEK value the detector's amount test
|
||||
* never ran, yet the candidate came back labelled 'exact_amount_*' and the
|
||||
* dialog claimed "på samma belopp" for amounts that were never compared.
|
||||
* The detector now returns reason 'date_window_only' + amount_verified:
|
||||
* false for that shape, and the dialog renders copy that does not claim an
|
||||
* amount match.
|
||||
*
|
||||
* This repo runs Vitest in the `node` environment and never renders
|
||||
* components, so like the sibling invoice-match-dialog-fx test these are
|
||||
* file-level assertions: the source must not carry the buggy expression, and
|
||||
* the strings it renders must exist in both locales.
|
||||
*/
|
||||
|
||||
const DIALOG_SRC = fs.readFileSync(path.resolve(__dirname, '../InvoiceMatchDialog.tsx'), 'utf8')
|
||||
const BOOKING_DIALOG_SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../DuplicateBookingDialog.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
const readMessages = (locale: 'sv' | 'en', namespace: string) =>
|
||||
(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, `../../../messages/${locale}.json`), 'utf8'),
|
||||
) as Record<string, Record<string, string>>
|
||||
)[namespace]
|
||||
|
||||
describe('InvoiceMatchDialog duplicate-candidate rendering', () => {
|
||||
it('formats the voucher leg amount as SEK, never in the transaction currency', () => {
|
||||
expect(DIALOG_SRC).not.toMatch(/formatCurrency\(candidate\.amount,\s*transaction\.currency\)/)
|
||||
expect(DIALOG_SRC).toMatch(/formatCurrency\(candidate\.amount,\s*'SEK'\)/)
|
||||
})
|
||||
|
||||
it('branches to unverified copy on date_window_only / amount_verified false', () => {
|
||||
expect(DIALOG_SRC).toContain("'date_window_only'")
|
||||
expect(DIALOG_SRC).toContain('amount_verified')
|
||||
expect(DIALOG_SRC).toContain("t('duplicate_body_unverified'")
|
||||
})
|
||||
|
||||
it('ships the unverified copy in both locales without claiming an amount match', () => {
|
||||
for (const locale of ['sv', 'en'] as const) {
|
||||
const messages = readMessages(locale, 'tx_invoice_match')
|
||||
const copy = messages.duplicate_body_unverified
|
||||
expect(copy).toBeTruthy()
|
||||
// next-intl throws on an unsupplied placeholder, so the parameter names
|
||||
// must match what the dialog passes.
|
||||
expect(copy).toContain('{label}')
|
||||
expect(copy).toContain('{amount}')
|
||||
expect(copy).toContain('{date}')
|
||||
// The whole point of the branch: no "same amount" claim.
|
||||
expect(copy).not.toMatch(/samma belopp|same amount/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DuplicateBookingDialog rateless-sibling warning', () => {
|
||||
it('gates the currency-naming warning on candidate.currency being present', () => {
|
||||
// t('dialog_duplicate_sek_unavailable', { currency }) interpolates the
|
||||
// currency into the sentence; an empty string renders broken Swedish, so
|
||||
// the branch must not fire for a null-currency candidate.
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(
|
||||
/candidate\.amount == null && candidate\.currency &&/,
|
||||
)
|
||||
expect(BOOKING_DIALOG_SRC).not.toMatch(/candidate\.currency \?\? ''/)
|
||||
})
|
||||
|
||||
it('keeps the warning strings in both locales', () => {
|
||||
for (const locale of ['sv', 'en'] as const) {
|
||||
const messages = readMessages(locale, 'transactions')
|
||||
expect(messages.dialog_duplicate_sek_unavailable).toContain('{currency}')
|
||||
expect(messages.dialog_duplicate_amount_unknown).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
MATCH_INVOICE_BOOKING_RATE_MISSING,
|
||||
isInvoiceBookingRateMissing,
|
||||
previewedFxGainSek,
|
||||
type InvoiceBookingRateInput,
|
||||
} from '../invoice-match-fx'
|
||||
|
||||
/**
|
||||
* The approval gate of the invoice-match dialog.
|
||||
*
|
||||
* The bug: the Valutaomräkning section computed its own kursvinst/kursförlust
|
||||
* from `potential_invoice.exchange_rate ?? 0` behind a `> 0` guard. An invoice
|
||||
* with no booked rate therefore previewed "no FX result", while the verifikat
|
||||
* built from the same screen posted a real gain (a 1 000 EUR invoice settled
|
||||
* by an 11 496,70 kr deposit produced a 10 496,70 kr kursvinst, because the
|
||||
* line builder fell back to rate 1). The user approved one number and got
|
||||
* another.
|
||||
*
|
||||
* buildInvoicePaymentClearingLines now throws
|
||||
* MATCH_INVOICE_BOOKING_RATE_MISSING instead of inventing the rate, and the
|
||||
* match-invoice preview GET and commit POST both surface that refusal, so the
|
||||
* dialog has to represent a refusal rather than a zero.
|
||||
*
|
||||
* The decision lives in ../invoice-match-fx.ts precisely so it can be tested:
|
||||
* this repo runs Vitest in the `node` environment and never renders
|
||||
* components, so a rule embedded in JSX is unverifiable by construction. These
|
||||
* tests therefore pin the helper's behaviour. Two file-level assertions remain
|
||||
* at the bottom for the two things a pure function cannot cover: that the
|
||||
* dialog no longer carries the defaulting expression that caused the bug, and
|
||||
* that the strings it renders exist in both locales.
|
||||
*/
|
||||
|
||||
const base: InvoiceBookingRateInput = {
|
||||
transactionCurrency: 'SEK',
|
||||
invoiceCurrency: 'EUR',
|
||||
invoiceExchangeRate: 11.4967,
|
||||
previewEntryType: 'clearing',
|
||||
previewErrorCode: null,
|
||||
}
|
||||
|
||||
describe('isInvoiceBookingRateMissing', () => {
|
||||
describe('state 1: no FX effect, nothing to warn about', () => {
|
||||
it('a pure SEK settlement is never a missing-rate case', () => {
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
transactionCurrency: 'SEK',
|
||||
invoiceCurrency: 'SEK',
|
||||
invoiceExchangeRate: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('a foreign invoice settled in its own currency needs no booking rate', () => {
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
transactionCurrency: 'EUR',
|
||||
invoiceCurrency: 'EUR',
|
||||
invoiceExchangeRate: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('a SEK invoice settled by a foreign bank transaction is bookable', () => {
|
||||
// fx_conversion.required is true here (the currencies differ), but the
|
||||
// line builder takes the !invoiceIsForeign branch, never reads
|
||||
// invoice.exchange_rate and posts no 3960/7960 line. SEK invoices always
|
||||
// have exchange_rate null, so keying off the rate alone would block
|
||||
// every one of these perfectly bookable settlements.
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
transactionCurrency: 'USD',
|
||||
invoiceCurrency: 'SEK',
|
||||
invoiceExchangeRate: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not fire on the cash entry, which never values an AR leg', () => {
|
||||
// Kontantmetoden + unbooked + fully paid posts revenue and VAT directly;
|
||||
// 1510 is never touched, so no booking rate is consulted and the line
|
||||
// builder cannot throw.
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
invoiceExchangeRate: null,
|
||||
previewEntryType: 'cash',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state 2: foreign invoice with a booked rate', () => {
|
||||
it('is bookable, so the dialog previews a real kursvinst/kursförlust', () => {
|
||||
expect(isInvoiceBookingRateMissing(base)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts rates across the whole valid band', () => {
|
||||
for (const rate of [0.0001, 1, 11.4967, 99999.9999]) {
|
||||
expect(isInvoiceBookingRateMissing({ ...base, invoiceExchangeRate: rate })).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('state 3: foreign invoice with no usable booked rate', () => {
|
||||
it('reports the settlement as unbookable', () => {
|
||||
expect(isInvoiceBookingRateMissing({ ...base, invoiceExchangeRate: null })).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an unusable stored rate the same as a missing one', () => {
|
||||
// Mirrors requireInvoiceBookingRate / the match_batch_allocate RPC
|
||||
// bound: 0 < rate < 100000. A rate outside it cannot value the 1510
|
||||
// credit, so it must not be smuggled through as a number.
|
||||
for (const rate of [0, -1, -11.4967, 100000, 1e9, Number.NaN, undefined, null]) {
|
||||
expect(isInvoiceBookingRateMissing({ ...base, invoiceExchangeRate: rate })).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('believes the server when the preview refused with the structured code', () => {
|
||||
// The live path: the preview GET 400s, so there is no entry_type and no
|
||||
// lines to reason about. The code alone must be enough.
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
transactionCurrency: 'SEK',
|
||||
invoiceCurrency: 'EUR',
|
||||
invoiceExchangeRate: null,
|
||||
previewEntryType: null,
|
||||
previewErrorCode: MATCH_INVOICE_BOOKING_RATE_MISSING,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('believes the code even when the client-side fields look fine', () => {
|
||||
// The server is authoritative: it built the lines with the same helper
|
||||
// the POST commits with. A stale or partial invoice row on the client
|
||||
// must never override a refusal that already happened.
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
previewErrorCode: MATCH_INVOICE_BOOKING_RATE_MISSING,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('does not over-trigger', () => {
|
||||
it('ignores an unrelated preview failure', () => {
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
previewEntryType: null,
|
||||
previewErrorCode: 'MATCH_INVOICE_TX_FX_RATE_MISSING',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays quiet while the preview is still in flight', () => {
|
||||
// Neither an entry type nor an error code yet: the dialog must not flash
|
||||
// a refusal panel before the server has said anything.
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
invoiceExchangeRate: null,
|
||||
previewEntryType: null,
|
||||
previewErrorCode: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not guess when the invoice currency is unknown', () => {
|
||||
expect(
|
||||
isInvoiceBookingRateMissing({
|
||||
...base,
|
||||
invoiceCurrency: null,
|
||||
invoiceExchangeRate: null,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewedFxGainSek', () => {
|
||||
const bank = { account_number: '1930', debit_amount: 11496.7, credit_amount: 0 }
|
||||
const ar = { account_number: '1510', debit_amount: 0, credit_amount: 11000 }
|
||||
|
||||
it('reads a kursvinst off the previewed 3960 credit', () => {
|
||||
expect(
|
||||
previewedFxGainSek([
|
||||
bank,
|
||||
ar,
|
||||
{ account_number: '3960', debit_amount: 0, credit_amount: 496.7 },
|
||||
]),
|
||||
).toBe(496.7)
|
||||
})
|
||||
|
||||
it('reads a kursförlust off the previewed 7960 debit as a negative', () => {
|
||||
expect(
|
||||
previewedFxGainSek([
|
||||
bank,
|
||||
ar,
|
||||
{ account_number: '7960', debit_amount: 496.7, credit_amount: 0 },
|
||||
]),
|
||||
).toBe(-496.7)
|
||||
})
|
||||
|
||||
it('reports no FX result when the previewed entry has no 3960/7960 line', () => {
|
||||
expect(previewedFxGainSek([bank, ar])).toBe(0)
|
||||
})
|
||||
|
||||
it('reports no FX result for an empty preview', () => {
|
||||
expect(previewedFxGainSek([])).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores öresavrundning (3740), which is not an FX result', () => {
|
||||
expect(
|
||||
previewedFxGainSek([
|
||||
bank,
|
||||
ar,
|
||||
{ account_number: '3740', debit_amount: 0.3, credit_amount: 0 },
|
||||
]),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('surfaces the full gain the verifikat posts, however large', () => {
|
||||
// The reported case, had the server built lines for it: a 1 000 EUR
|
||||
// receivable valued at rate 1 against an 11 496,70 kr deposit books a
|
||||
// 10 496,70 kr kursvinst. The old screen recomputed its own figure and
|
||||
// showed 0. Reading the number off the previewed lines means the figure
|
||||
// approved is the figure booked, by construction.
|
||||
expect(
|
||||
previewedFxGainSek([
|
||||
{ account_number: '1930', debit_amount: 11496.7, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
|
||||
{ account_number: '3960', debit_amount: 0, credit_amount: 10496.7 },
|
||||
]),
|
||||
).toBe(10496.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the dialog the helpers serve', () => {
|
||||
const SRC = fs.readFileSync(path.resolve(__dirname, '../InvoiceMatchDialog.tsx'), 'utf8')
|
||||
const readMessages = (locale: 'sv' | 'en') =>
|
||||
(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, `../../../messages/${locale}.json`), 'utf8'),
|
||||
) as Record<string, Record<string, string>>
|
||||
).tx_invoice_match
|
||||
|
||||
it('never defaults a missing exchange rate to a number', () => {
|
||||
// The literal shape of the bug. A pure function cannot see this, and a
|
||||
// reviewer reintroducing it would otherwise get a green suite: every
|
||||
// helper test above would still pass while the dialog quietly recomputed
|
||||
// its own zero.
|
||||
expect(SRC).not.toMatch(/exchange_rate\s*(\?\?|\|\|)\s*-?\d/)
|
||||
})
|
||||
|
||||
it('ships the missing-rate strings in both locales, with the currency placeholder', () => {
|
||||
// No repo-wide sv/en parity check exists, and a key present in only one
|
||||
// file renders its own name at the user.
|
||||
for (const locale of ['sv', 'en'] as const) {
|
||||
const messages = readMessages(locale)
|
||||
expect(messages.fx_invoice_rate_missing_title).toBeTruthy()
|
||||
expect(messages.fx_invoice_rate_missing_description).toBeTruthy()
|
||||
// next-intl throws on an unsupplied placeholder, so the parameter name
|
||||
// has to match what the dialog passes.
|
||||
expect(messages.fx_invoice_rate_missing_description).toContain('{invoiceCurrency}')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Pure amount-proximity ranking for the manual invoice pickers
|
||||
* (InvoicePicker, SupplierInvoicePicker).
|
||||
*
|
||||
* Lives outside those components (both `'use client'`) so the rule below can be
|
||||
* unit tested: this repo runs Vitest in the `node` environment and never
|
||||
* renders components, so logic embedded in JSX is unverifiable by construction.
|
||||
*
|
||||
* # Why this exists
|
||||
*
|
||||
* Both pickers ranked candidates by `Math.abs(remaining - |tx.amount|)` with no
|
||||
* regard for currency, so a 1 000 EUR invoice sorted to the very top as a
|
||||
* perfect match for a 1 000 SEK bank row: same number, roughly eleven times the
|
||||
* money. The genuine 1 000 SEK invoice sat below it, and the pre-selected row
|
||||
* in a two-click flow was the wrong one.
|
||||
*
|
||||
* This is a RANKING fix, not a filter. The manual pickers deliberately list
|
||||
* every open invoice regardless of currency, because the user must still be
|
||||
* able to settle a foreign invoice by hand. Nothing is ever removed from the
|
||||
* list here; rows only move.
|
||||
*
|
||||
* # The three tiers
|
||||
*
|
||||
* 1. `same_currency`: both sides carry the same currency, so the difference
|
||||
* is exact money. Ranked by that difference. Only this tier may claim a
|
||||
* match.
|
||||
* 2. `converted_sek`: the currencies differ but both sides have a
|
||||
* deterministic SEK value, so a real difference exists. Ranked by it,
|
||||
* behind an equally close same-currency row.
|
||||
* 3. `incomparable`: no shared unit exists (a foreign invoice with no booked
|
||||
* exchange rate, or a foreign bank row with no `amount_sek`). Still
|
||||
* listed, never ranked on a meaningless number: these sink to the bottom
|
||||
* in invoice-date order.
|
||||
*
|
||||
* Tiers 1 and 2 rank against each other by their difference rather than
|
||||
* strictly by tier. A SEK deposit of 11 500 kr settling a 1 000 EUR invoice is
|
||||
* the whole reason cross-currency matching exists; burying that invoice under
|
||||
* every wildly-off SEK invoice would trade one bad ranking for another.
|
||||
* `BankTransactionPicker` puts same-currency rows strictly first instead, and
|
||||
* that is right for it: its target is a half-typed invoice form whose amount
|
||||
* has no SEK equivalent yet, so tier 2 is unreachable there and the tie-break
|
||||
* never comes up. Here both sides are persisted rows that carry their own
|
||||
* conversion data.
|
||||
*
|
||||
* The SEK conversion mirrors `match_batch_allocate` (migration
|
||||
* 20260531120000_match_batch_allocate_cross_currency.sql), which values a
|
||||
* cross-currency settlement as `invoice.remaining * invoice.exchange_rate` and
|
||||
* refuses the match outright (BATCH_FX_RATE_MISSING) when that rate is missing.
|
||||
* Ranking on the same rule means the order the user sees agrees with what the
|
||||
* booking path will actually accept.
|
||||
*/
|
||||
import { isValidExchangeRate } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* `transactions.currency` and `invoices.currency` are both nullable
|
||||
* (`text default 'SEK'`, no NOT NULL); `supplier_invoices.currency` is NOT
|
||||
* NULL. A NULL or blank value has always meant a domestic row, so a plain
|
||||
* `a.currency === b.currency` would read NULL as "some other currency" and
|
||||
* demote a perfectly ordinary SEK invoice into the unranked tier. Normalize
|
||||
* before every comparison.
|
||||
*/
|
||||
export const DOMESTIC_CURRENCY = 'SEK'
|
||||
|
||||
export function normalizeCurrency(currency: string | null | undefined): string {
|
||||
const normalized = (currency ?? '').trim().toUpperCase()
|
||||
return normalized === '' ? DOMESTIC_CURRENCY : normalized
|
||||
}
|
||||
|
||||
/** Money rounding per CLAUDE.md: never `toFixed()`. */
|
||||
function roundOre(amount: number): number {
|
||||
return Math.round(amount * 100) / 100
|
||||
}
|
||||
|
||||
export interface ProximityTarget {
|
||||
/** Bank transaction amount in `currency`. Sign is ignored. */
|
||||
amount: number
|
||||
/** `transactions.currency`. Nullable in the DB; normalized to SEK. */
|
||||
currency: string | null | undefined
|
||||
/**
|
||||
* `transactions.amount_sek`. Null on domestic rows and on rows that predate
|
||||
* the column, which is exactly why a foreign bank row can be incomparable.
|
||||
*/
|
||||
amountSek?: number | null
|
||||
}
|
||||
|
||||
export interface ProximityCandidate {
|
||||
/** Outstanding amount in `currency`. Sign is ignored. */
|
||||
amount: number
|
||||
currency: string | null | undefined
|
||||
/**
|
||||
* `invoices.exchange_rate` / `supplier_invoices.exchange_rate`: the rate the
|
||||
* receivable/payable was booked at, candidate currency -> SEK. Null on SEK
|
||||
* invoices by construction, and null on foreign invoices whose rate was
|
||||
* never established.
|
||||
*/
|
||||
exchangeRate?: number | null
|
||||
}
|
||||
|
||||
export type ProximityBasis = 'same_currency' | 'converted_sek' | 'incomparable'
|
||||
|
||||
export interface AmountProximity {
|
||||
basis: ProximityBasis
|
||||
/** `|target - candidate|` expressed in `diffCurrency`. Null when incomparable. */
|
||||
diff: number | null
|
||||
/** The currency `diff` is expressed in. Null when incomparable. */
|
||||
diffCurrency: string | null
|
||||
/**
|
||||
* The candidate amount converted to SEK. Null when no conversion happened
|
||||
* (the candidate is already SEK) or none is possible (no usable booked
|
||||
* rate). Set independently of whether the comparison itself succeeded, so
|
||||
* the row can show its SEK value even when the bank side is unconvertible.
|
||||
*/
|
||||
candidateSek: number | null
|
||||
/**
|
||||
* Exact hit, to the öre. Stays false on a converted comparison on purpose:
|
||||
* that figure values the invoice at its own booking rate, not at what the
|
||||
* bank actually moved, so the two agreeing to the öre would be a
|
||||
* coincidence rather than a confirmation. Such a row still ranks by its
|
||||
* converted difference; it just never claims to be a match.
|
||||
*/
|
||||
exact: boolean
|
||||
/** Near hit: within 1% of the target. Same-currency only, same reasoning. */
|
||||
close: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidate amount in SEK, or null when no conversion happened or none is
|
||||
* possible. Deliberately returns null (not the amount) for a SEK candidate:
|
||||
* callers use a non-null value to mean "a conversion was performed", which is
|
||||
* the only case worth showing next to the row.
|
||||
*/
|
||||
function convertToSek(
|
||||
amount: number,
|
||||
currency: string,
|
||||
exchangeRate: number | null | undefined,
|
||||
): number | null {
|
||||
if (currency === DOMESTIC_CURRENCY) return null
|
||||
if (!isValidExchangeRate(exchangeRate)) return null
|
||||
return roundOre(amount * exchangeRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* How close a candidate invoice is to a bank transaction, and on what basis.
|
||||
* Never throws and never guesses: an unconvertible pair reports
|
||||
* `incomparable` rather than comparing two numbers in different units.
|
||||
*/
|
||||
export function compareAmountProximity(
|
||||
target: ProximityTarget,
|
||||
candidate: ProximityCandidate,
|
||||
): AmountProximity {
|
||||
const targetAmount = Math.abs(target.amount)
|
||||
const targetCurrency = normalizeCurrency(target.currency)
|
||||
const candidateAmount = Math.abs(candidate.amount)
|
||||
const candidateCurrency = normalizeCurrency(candidate.currency)
|
||||
|
||||
if (targetCurrency === candidateCurrency) {
|
||||
const diff = roundOre(Math.abs(candidateAmount - targetAmount))
|
||||
return {
|
||||
basis: 'same_currency',
|
||||
diff,
|
||||
diffCurrency: candidateCurrency,
|
||||
candidateSek: null,
|
||||
exact: diff < 0.01,
|
||||
close: diff >= 0.01 && targetAmount > 0 && diff / targetAmount < 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
const candidateSek = convertToSek(candidateAmount, candidateCurrency, candidate.exchangeRate)
|
||||
const candidateInSek = candidateCurrency === DOMESTIC_CURRENCY ? candidateAmount : candidateSek
|
||||
const targetInSek =
|
||||
targetCurrency === DOMESTIC_CURRENCY
|
||||
? targetAmount
|
||||
: target.amountSek != null
|
||||
? Math.abs(target.amountSek)
|
||||
: null
|
||||
|
||||
if (candidateInSek == null || targetInSek == null) {
|
||||
return {
|
||||
basis: 'incomparable',
|
||||
diff: null,
|
||||
diffCurrency: null,
|
||||
candidateSek,
|
||||
exact: false,
|
||||
close: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
basis: 'converted_sek',
|
||||
diff: roundOre(Math.abs(candidateInSek - targetInSek)),
|
||||
diffCurrency: DOMESTIC_CURRENCY,
|
||||
candidateSek,
|
||||
exact: false,
|
||||
close: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural shape shared by `Invoice` and `SupplierInvoice`. */
|
||||
export interface RankableInvoice {
|
||||
remaining_amount?: number | null
|
||||
total: number
|
||||
currency?: string | null
|
||||
exchange_rate?: number | null
|
||||
invoice_date: string
|
||||
}
|
||||
|
||||
export interface RankedInvoice<T> {
|
||||
invoice: T
|
||||
proximity: AmountProximity
|
||||
}
|
||||
|
||||
const BASIS_RANK: Record<ProximityBasis, number> = {
|
||||
same_currency: 0,
|
||||
converted_sek: 1,
|
||||
incomparable: 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders open invoices for a manual picker: closest comparable amount first,
|
||||
* unrankable rows last but never dropped. Ties fall back to an exact-unit
|
||||
* comparison over an FX estimate, then to newest invoice first, which is the
|
||||
* order the pickers used before amount proximity existed.
|
||||
*/
|
||||
export function rankInvoicesByAmountProximity<T extends RankableInvoice>(
|
||||
invoices: T[],
|
||||
target: ProximityTarget,
|
||||
): RankedInvoice<T>[] {
|
||||
return invoices
|
||||
.map((invoice) => ({
|
||||
invoice,
|
||||
proximity: compareAmountProximity(target, {
|
||||
amount: invoice.remaining_amount ?? invoice.total,
|
||||
currency: invoice.currency,
|
||||
exchangeRate: invoice.exchange_rate,
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const diffA = a.proximity.diff
|
||||
const diffB = b.proximity.diff
|
||||
if ((diffA == null) !== (diffB == null)) return diffA == null ? 1 : -1
|
||||
if (diffA != null && diffB != null && diffA !== diffB) return diffA - diffB
|
||||
const basisDelta = BASIS_RANK[a.proximity.basis] - BASIS_RANK[b.proximity.basis]
|
||||
if (basisDelta !== 0) return basisDelta
|
||||
return b.invoice.invoice_date.localeCompare(a.invoice.invoice_date)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pure FX predicates for the invoice-match confirm dialog.
|
||||
*
|
||||
* Lives outside InvoiceMatchDialog.tsx (which is a `'use client'` component)
|
||||
* so the rule below can be unit tested: this repo runs Vitest in the `node`
|
||||
* environment and never renders components, so logic embedded in JSX is
|
||||
* unverifiable by construction.
|
||||
*
|
||||
* # Why this exists
|
||||
*
|
||||
* A cross-currency settlement has THREE distinct states, and the dialog has to
|
||||
* show a different thing for each:
|
||||
*
|
||||
* 1. No FX at all: the invoice and the bank transaction share a currency, or
|
||||
* the invoice is a SEK invoice (a SEK receivable already carries its own
|
||||
* SEK value). Nothing to convert, nothing to warn about.
|
||||
* 2. Foreign invoice WITH a booked rate: the kursvinst / kursförlust is a
|
||||
* real, computable number and the dialog previews it.
|
||||
* 3. Foreign invoice WITHOUT a booked rate: the SEK value the receivable was
|
||||
* posted at was never established, so the FX result of the settlement is
|
||||
* UNDEFINED, not zero. buildInvoicePaymentClearingLines refuses to build
|
||||
* the verifikat at all (MATCH_INVOICE_BOOKING_RATE_MISSING), so the
|
||||
* dialog must say the booking cannot be made rather than preview a
|
||||
* confident figure.
|
||||
*
|
||||
* State 3 was previously indistinguishable from state 1 on screen: the dialog
|
||||
* read `invoice.exchange_rate ?? 0` behind a `> 0` guard, so a missing rate
|
||||
* suppressed the FX note entirely and the user approved an apparent "no FX
|
||||
* effect" while the committed entry posted a phantom kursvinst.
|
||||
*/
|
||||
import { isValidExchangeRate } from '@/lib/utils'
|
||||
import { MATCH_INVOICE_BOOKING_RATE_MISSING } from '@/lib/bookkeeping/invoice-payment-lines'
|
||||
|
||||
export { MATCH_INVOICE_BOOKING_RATE_MISSING }
|
||||
|
||||
export interface InvoiceBookingRateInput {
|
||||
/** Currency of the bank transaction being matched. */
|
||||
transactionCurrency: string | null | undefined
|
||||
/** Currency the invoice was issued in. */
|
||||
invoiceCurrency: string | null | undefined
|
||||
/** `invoices.exchange_rate`: the rate the 1510 receivable was booked at. */
|
||||
invoiceExchangeRate: number | null | undefined
|
||||
/**
|
||||
* `entry_type` of a SUCCESSFUL preview, or null when the preview route
|
||||
* returned an error (or has not resolved yet).
|
||||
*/
|
||||
previewEntryType: 'clearing' | 'cash' | null
|
||||
/**
|
||||
* `error.code` from a FAILED preview response, or null. The preview route
|
||||
* calls the same line builder the POST commits with, so a
|
||||
* MATCH_INVOICE_BOOKING_RATE_MISSING here is the authoritative answer: the
|
||||
* server has already refused to value the settlement.
|
||||
*/
|
||||
previewErrorCode: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the settlement cannot be booked because the invoice carries no
|
||||
* usable exchange rate. Mirrors `requireInvoiceBookingRate` in
|
||||
* `lib/bookkeeping/invoice-payment-lines.ts` so the dialog blocks exactly the
|
||||
* cases the booking path refuses, and no others.
|
||||
*
|
||||
* Two independent routes to true, deliberately:
|
||||
*
|
||||
* - The preview route already threw with the structured code. This is the
|
||||
* live path today: the preview 400s, so there are no lines to render and
|
||||
* the dialog has nothing but the code to go on.
|
||||
* - The preview SUCCEEDED on a clearing entry whose invoice rate is
|
||||
* unusable. Unreachable while the route keeps throwing, but it keeps the
|
||||
* dialog honest if the route ever starts returning lines for this case
|
||||
* instead: the screen must never present a bookable-looking preview whose
|
||||
* FX result is undefined.
|
||||
*
|
||||
* Deliberately NOT true for a SEK invoice settled by a foreign bank
|
||||
* transaction. `fx_conversion.required` is set there too (the currencies
|
||||
* differ), but the line builder takes the `!invoiceIsForeign` branch, never
|
||||
* consults `invoice.exchange_rate`, and posts no 3960/7960 line. A SEK invoice
|
||||
* has `exchange_rate = null` by construction (`lib/invoices/build-invoice-
|
||||
* write.ts` only fetches a rate for non-SEK invoices), so keying off the rate
|
||||
* alone would block every one of those perfectly bookable settlements.
|
||||
*/
|
||||
export function isInvoiceBookingRateMissing(input: InvoiceBookingRateInput): boolean {
|
||||
if (input.previewErrorCode === MATCH_INVOICE_BOOKING_RATE_MISSING) return true
|
||||
|
||||
// Only the clearing entry values the AR leg at the invoice's booking rate.
|
||||
// The cash entry (kontantmetoden, unbooked invoice, fully paid) posts
|
||||
// revenue and VAT directly and never touches 1510.
|
||||
if (input.previewEntryType !== 'clearing') return false
|
||||
|
||||
const { transactionCurrency, invoiceCurrency, invoiceExchangeRate } = input
|
||||
if (!invoiceCurrency || invoiceCurrency === 'SEK') return false
|
||||
if (!transactionCurrency || transactionCurrency === invoiceCurrency) return false
|
||||
|
||||
return !isValidExchangeRate(invoiceExchangeRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The kursvinst / kursförlust the previewed verifikat actually posts, in SEK.
|
||||
* Positive = kursvinst (3960 credit), negative = kursförlust (7960 debit),
|
||||
* 0 = the entry has no FX line.
|
||||
*
|
||||
* Read OFF the previewed lines rather than recomputed from the invoice on
|
||||
* purpose: the preview and the POST both build their lines with
|
||||
* buildInvoicePaymentClearingLines, so the figure the user approves is the
|
||||
* figure that gets booked, by construction. The previous local recomputation
|
||||
* could disagree with it in two ways: it defaulted a missing invoice rate to
|
||||
* zero (previewing no FX result while a phantom kursvinst got booked), and it
|
||||
* read `transaction.amount` as SEK, which is wrong whenever the bank
|
||||
* transaction is itself in a foreign currency.
|
||||
*/
|
||||
export function previewedFxGainSek(
|
||||
lines: { account_number: string; debit_amount: number; credit_amount: number }[],
|
||||
): number {
|
||||
const fxLine = lines.find((l) => l.account_number === '3960' || l.account_number === '7960')
|
||||
if (!fxLine) return 0
|
||||
return fxLine.account_number === '3960' ? fxLine.credit_amount : -fxLine.debit_amount
|
||||
}
|
||||
Reference in New Issue
Block a user