Commit Graph

6 Commits

Author SHA1 Message Date
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00
Mattsson fdb5f6f891 feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

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

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

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

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

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

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

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

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

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

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

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

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

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

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

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

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

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

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

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

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

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

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

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

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

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

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

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

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

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

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

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

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

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

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

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

* fix(byra): drop the 'what's coming' tail from the automations intro

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

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

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

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

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

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

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

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

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

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

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

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

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

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

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

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

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

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

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

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

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

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

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

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

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

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

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

* fix(white-label): render hero brand logo at 64px on auth pages

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

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

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

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

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

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

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

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

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

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 16:56:39 +02:00
Jakob Wennberg 2d97fbf1bc fix: false popup-blocked toast on Visa dokument + scope SKV reconnect line to skattekonto source (#1613)
* fix(documents): stop false popup-blocked toast on Visa dokument

window.open() returns null BY SPEC when 'noopener' is in the features
string, even when the tab opens, so the destructive 'Tillåt popupfönster'
toast fired on every successful open. Open without the features string and
sever the reverse channel manually (tab.opener = null), the same pattern
lib/browser/deferred-tab.ts already uses; the toast now fires only on a
genuine popup block.

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

* fix(transactions): scope the SKV reconnect line to the skattekonto source

The reconnect attn line rendered on /transactions whenever the SKV
connection needed renewal, regardless of what the user was looking at, so
it read as permanent noise. It now shows only when the source picker is on
Skatteverket (the rows it actually explains); the skattekonto page keeps
its own reconnect line.

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

* fix(transactions): keep Skatteverket source pickable while reconnect is needed

In the reconnect-needed state the transaktioner fetch 401s, skvRows goes
empty, the Skatteverket option left the source picker, and the stale-filter
effect reset the filter to 'all': the source-gated reconnect line became
unreachable exactly when it applied. Show the source whenever rows exist OR
reconnect is needed (skvNeedsReconnect already requires connected=true, so
never-connected companies get no phantom source).

No component test: repo test scope is lib/ + app/api/ (no component tests).

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 10:04:36 +02:00
Jakob Wennberg c62d00bcb3 feat(invoices): preview invoices and underlag in the browser instead of downloading (#1228)
Reviewing an invoice or a verifikat bilaga meant saving a file and opening it
from the Downloads folder (user request, christian@odinaero.se 2026-07-25).

- GET /api/invoices/[id]/pdf accepts ?disposition=inline and serves the PDF for
  in-browser review; anything else keeps the download behaviour every existing
  caller relies on. The filename still travels in the header, so the browser
  viewer's own save action produces the same name as the download button, and
  nosniff pins the content type.
- The invoice detail page gets a "Förhandsgranska" action next to "Ladda ner
  PDF". It resolves the document through the same resolveInvoicePdfSource path
  as the download, so preview cannot become the shortcut that presents a
  re-render as the invoice the customer received: the archived delivery wins,
  a re-render is shown with its caveat, and an unreadable delivery history
  still asks instead of guessing. The archive dialog now remembers whether the
  user asked to view or to save, and its fallback does that.
- DocumentViewButton (supplier-invoice underlag, staged agent previews) points
  at the existing /api/documents/:id/inline proxy, so bilagor render in the
  browser. Navigation now happens straight from the click, so the signed-URL
  fetch and its popup-blocker workaround are gone.
- The three re-render caveat strings and the two archive-dialog descriptions
  lose their "you downloaded" wording so they stay true for both actions;
  five new keys in sv + en.

Tests: route cases for the default, inline and unknown disposition values;
invoiceRerenderUrl cases for both modes and id encoding. npm test 11364
passed, lint 0 errors. Button row screenshotted against the design system
(pill outline, Eye icon) via a temporary sandbox route.

Closes #1190

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:59:33 +02:00
Jakob Wennberg f07a34c51b fix(invoices): stop popup blockers from silently eating the PDF preview tab (#1191)
* fix(invoices): stop popup blockers from silently eating the PDF preview tab

A window.open() after an await runs outside the click's transient user
activation (~5s, less in Safari), so the preview tab was popup-blocked
exactly when generation was slow (cold start + logo re-encode). The
request succeeded, nothing opened, no error: the button looked locked
(support: carina@cbysea.se).

- lib/browser/deferred-tab.ts: open the tab synchronously in the click,
  navigate it when the result arrives, close it on failure (the pattern
  AGIPanel already used for its signing tab), with unit tests.
- InvoiceEditor: preview uses the deferred tab + popup-blocked toast;
  revoke the blob URL instead of leaking it; guard the review dialog
  against an unresolved customer (silent no-op click); 5s timeout on the
  pre-review next-number fetch; spinner + disable while the submit
  handler is in flight in create mode.
- Same pre-open fix in TransactionAttachmentIndicator,
  JournalEntryAttachments (failures now toast instead of vanishing),
  DocumentViewButton, and the Arcim reconnect OAuth popup (its 'trusted
  gesture' comment was wrong after the await).

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

* fix(invoices): review triage: close blocked preview tab, precise popup hint, test convention

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:43:04 +02:00
Jakob Wennberg 39204cc0de UX polish bundle: Enable Banking lookback + sync progress, invoice inbox, matching previews (#548)
* fix(import): dedup opening-balance rows when account numbers differ only in whitespace

The parser's merge map keyed on the post-strip account_number, but rows like
"1930", " 1930 " and "1.930" could leak as separate entries when the
upstream string contained non-breaking spaces or zero-width chars that the
old .replace(/[^0-9]/g, '') ran on already-stripped output. Strip those
explicitly in the raw string and use /\D/g for the digit extraction.

Also adds defense-in-depth dedup inside OpeningBalanceEditStep so any
duplicates that survive the parser collapse before the user sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enable-banking): anchor lookback picker to fiscal year, not days

Replaces the 90/180/365 days dropdown on the account-selection screen
with three explicit modes:

- "Senaste 90 dagar (snabbt)" — fastest path, matches PSD2 ceiling
- "Sedan räkenskapsårets början" (default) — resolves via
  fiscal_year_start_month, surfaces the literal date inline
- "Anpassat datum" — free date picker OR "Föregående räkenskapsårets start"

When the resulting range exceeds 90 days, the picker now surfaces a
quiet helper that points users at the SIE/bankfil import for older
history, so they don't waste an account-selection round-trip discovering
that banks usually cap at ~90 days.

The PATCH /accounts handler accepts initial_lookback_from_date alongside
initial_lookback_days; the new helper getCurrentFiscalYearStart() in
lib/company/fiscal-year.ts is reused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(enable-banking): dedicated sync progress modal replaces silent spinner

After the user confirms account selection, transactions fetch in the
background for 30–60 seconds. Previously this showed only the Spara-button
spinner with no indication of duration or what was happening — users
described being stuck on the page.

The new BankSyncProgressDialog opens immediately on Save, lists the enabled
accounts being synced, and disables manual close until the PATCH resolves.
On completion it shows the imported count and the actual date range the
bank returned, plus an amber escape hatch to SIE/bankfil import when the
returned range was truncated by >7 days from what was requested.

Failure path surfaces in the same modal rather than as a destructive toast
that disappears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): drop duplicate Skapa leverantör button

The inbox detail panel had its own supplier-creation button that fired
/api/suppliers + match-supplier. The same action is reachable from the
supplier-invoice form's "Skapa & välj" card (showAISupplierHint), which
also prefills more fields. The duplicate button is gone; a quiet inline
hint replaces it so the user still knows why no supplier matched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(invoice-inbox): surface currency and totals above the long metadata tail

Move Valuta / Totalt / Moms in FIELD_DEFS so they sit immediately under
Leverantör / Org.nr / VAT-nr. These are the fields the user reads first
when triaging an inbox item; burying them after nine metadata fields
forces unnecessary scrolling on every single invoice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(invoice-inbox): accept .eml forwards and log rejected attachments

Gmail's "Forward as attachment" packages the original email as message/rfc822,
which our MIME allowlist silently dropped. Adds mailparser so we can unwrap
the inner attachments and ingest them under the inner email's subject/from.

Also persists every rejected attachment as an invoice_inbox_items row with
status='error', so users can see what was dropped instead of guessing why
nothing showed up in their inbox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): redirect back to inbox after creating from invoice-inbox

When the leverantörsfaktura form is opened from an invoice-inbox item, every
successful create previously kicked the user out to /supplier-invoices or the
just-created invoice's detail page — derailing the "process the next
document" workflow. The Tillbaka button likewise routed to the
supplier-invoice list rather than the inbox they came from.

Adds an afterCreate helper that lands inbox-originated submissions at
/e/general/invoice-inbox and preserves the original target everywhere else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui(pending): show transaction/document context for match-and-attach reviews

The granskning page previously rendered attach_document_to_transaction and
match_transaction_invoice operations through the generic key/value preview,
so reviewers saw "document file name: Faktura.pdf / transaction amount:
-216 USD" without any visual indication of which two things were being
paired. The MCP tool already returns enriched preview data; we just
needed dedicated layouts.

Adds:
- AttachDocumentPreview — two-card layout (Transaktion | Dokument) with
  a "Visa dokument" button that fetches a signed download URL on demand
- MatchTransactionInvoicePreview — same layout (Transaktion | Faktura)
- DocumentViewButton — reusable signed-URL opener

Also tightens the matching tools' descriptions so AI clients are nudged
to verify human-readable context before staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address PR #548 review feedback

- invoice-inbox: hoist mailparser to a static import. The extension system
  generates a static import tree via setup:extensions and disallows dynamic
  imports — await import('mailparser') worked in dev but could fail in
  production standalone builds.
- enable-banking AccountPickerDialog: guard the Save path when "Anpassat
  datum" + "Specifikt datum" is selected with an empty date. Without this,
  lookback.body resolves to null and the PATCH silently falls back to the
  backend's 120-day default, ignoring the user's intent.
- enable-banking BankSyncProgressDialog: drop the empty-body useEffect.
  Close-prevention is already handled inline via the onOpenChange guard +
  onPointerDownOutside + onEscapeKeyDown handlers.
- lib/company/fiscal-year: pin both operands of daysBetween() to UTC when
  parsing ISO date strings. Mixing a UTC-parsed date with new Date() (local
  time) drifts by one day in any timezone east of UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address compliance swarm + Swedish review feedback

Three actionable items from the post-fix compliance scan; the rest were
false positives or out of scope.

- enable-banking PATCH /accounts: reject future initial_lookback_from_date
  with 400 instead of silently falling through to the 120-day default.
  Compliance V2.2.
- AttachDocumentPreview: promote the overwrite warning to a destructive
  banner with BFL 7 kap context when the existing document is marked as
  räkenskapsinformation. A muted footnote was too easy to skip past for
  a verifikationsunderlag replacement.
- MatchTransactionInvoicePreview: surface transaction_date + invoice_date
  in the staged preview so reviewers can spot date drift before approving
  (BFL 5 kap 6§ — verifikation date must align with affärshändelse). Also
  shows a quiet hint when the two dates differ by > 31 days. Tool's SELECT
  + stage payload extended accordingly.

Skipped (with rationale):
- V5.3 inner.filename path traversal — lib/core/documents/document-service.ts
  already sanitizes filenames before constructing storage paths.
- V5.2 magic-number MIME — pre-existing pattern for all email attachments;
  scope is codebase-wide.
- V1.2 att.id composite ID — only used as a DB column value, never a path.
- V13.1 / CM-8 SBOM/SCA — repository-wide policy, not this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address second-round compliance + Swedish review feedback

Compliance Swarm (defense-in-depth + valid finds):
- invoice-inbox: sanitise .eml inner attachment filenames and content-types
  before they flow into uploadAndExtract or the raw_email_payload JSONB.
  document-service already strips bad chars before constructing storage
  paths, but the swarm flagged the upstream input as unsanitised — easier
  to add a thin sanitiseFilename/sanitiseMime layer than to argue about
  defense-in-depth. Caps lengths too.
- DocumentViewButton: validate documentId as a UUID before interpolating
  into /api/documents/:id — staged preview_data is Record<string, unknown>
  on the wire, so refusing junk early gives a clearer error and keeps the
  internal API from seeing oddly-shaped path segments. (Compliance V1.2.)

Swedish review:
- MatchTransactionInvoicePreview: drop the BFL 5 kap 6§ citation from the
  date-drift hint — that section governs verifikationsinnehåll, not a 31-day
  tolerance. The hint stays (the practical concern is real) but no longer
  pretends to quote a legislated threshold.
- fiscal-year: document the implicit assumption that entity_type reflects
  the company's current tax-year status, not a mid-conversion state.

Skipped (with rationale):
- V5.2 magic-number MIME — pre-existing pattern across all email attachments.
- A.8.12 signed URL via window.open — pre-existing pattern shared with
  JournalEntryAttachments.tsx; refactor to server-side redirect is broader scope.
- A.8.15 logRejection failure path — pre-existing console.error pattern.
- CC9.2 mailparser vendor review / SBOM — out of PR scope.
- CC6.1 IDOR — /api/documents/:id already enforces company_id; false positive.
- Swedish #1 räkenskapsinformation flag origin — server-side already derives
  the flag from document_attachments.journal_entry_id in the staging tool;
  not caller-trusted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): fail-safe BFL warning + preserve merge validation errors

Two findings from the third compliance pass; both worth addressing.

- AttachDocumentPreview: treat an absent
  existing_document_is_rakenskapsinformation flag as räkenskapsinformation
  rather than as "safe to overwrite". The MCP staging tool sets the flag
  deterministically from document_attachments.journal_entry_id today, but
  a future code path that forgets it would silently downgrade the BFL 7
  kap warning. Only an explicit `=== false` from the server keeps the
  muted note path.
- Opening-balance merge: union validation_errors when collapsing duplicate
  account_number rows, both in the parser and the EditStep useState
  initializer. Previously a warning that fired on row 5 (e.g. BAS-class
  mismatch) was silently dropped if row 2 of the same account had no error,
  risking misclassified IB data downstream. Added a parser test covering
  the union behaviour for two rows of a class-3 (resultatkonto) account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:10:12 +02:00