main
1676 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6a0ba9faaa |
Merge pull request 'feat(toolchain): W4-slutvåg — mise task-kontrakt + lefthook + template-pin + dokumentation' (#2) from feat/selected-toolchain-w4 into main
masterplan-lock / check (push) Successful in 5s
|
||
|
|
42241a9ad3 | feat(toolchain): W4-slutvåg — mise task-kontrakt (genererad ur toolchain-discovery) + lefthook + template-pin + dokumentation | ||
|
|
07eb82af5d |
feat(toolchain): W4-slutvåg — mise task-kontrakt + lefthook + template-pin + dokumentation
masterplan-lock / check (push) Successful in 5s
|
||
|
|
5b7015d88d |
fix(plan): komplettera lock-scriptet-krav (aldrig egen SQL, frontend-tenant-trust, PRODUCTION_READY)
masterplan-lock / check (push) Successful in 5s
|
||
|
|
d8463e7ffe |
feat(scaffold): SIAX masterplan-lock-gate + PLAN/MASTERPLAN_INDEX.md integrering
masterplan-lock / check (push) Failing after 5s
|
||
|
|
51f05ffeba |
feat(dashboard): dismissible system notice banner for every signed-in user (#2464)
CodeQL / Analyze (javascript-typescript) (push) Failing after 10m53s
CodeQL / Analyze (actions) (push) Failing after 10m43s
Build and Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build and Push Docker Image / Merge, sign and scan (push) Has been cancelled
Build and Push Docker Image / Build linux/amd64 (push) Failing after 3m4s
Workflow audit (zizmor) / Audit workflows (push) Failing after 5m54s
* feat(dashboard): dismissible system notice banner for every signed-in user
Operator-set banner ("high load right now, some pages may respond slowly
or fail") rendered under the dashboard chrome for every signed-in user
while NEXT_PUBLIC_SYSTEM_NOTICE_UNTIL (ISO timestamp with offset) is in
the future. Closing it stores the deadline in localStorage, so each
browser sees it once; the banner hides itself at the deadline in open
tabs and is not rendered at all after it.
Why the problem occurred: there was no way to tell every user something
about the system itself. The existing banners are all per-company state
(sandbox, seat grace), so an operator notice had no home.
What was removed or simplified instead: no notices table, no migration,
no admin UI. One public env var carries both the on/off switch and the
expiry, and the same value is the dismiss key, so a later notice re-shows
once without any code change. No DB read, which matters because the
first use is a DB restart window.
Why this over the proposed shape: the request was a banner "until 23:00
tonight". Hardcoding that in code would need a second PR to switch off
or reuse; a DB-backed notice would read the database that is about to
go down. The env var expires on its own, and unset means gone.
Fixes #2463
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs9PfHL7KpdidvUdxVkHXF
* fix(dashboard): system notice survives long deadlines, blocked storage, and every layout shell
Skeptic findings on 839a255f3:
- setTimeout clamps delays above 2^31-1 ms to ~1 ms, so a deadline more
than 24.8 days out hid the banner instantly. Wait in bounded steps and
re-check the clock.
- window.localStorage is a throwing property access when a browser blocks
site data; read it behind a try so the dashboard never crashes over a
notice.
- The close button was a hand-rolled 22px icon button; design.md requires
the shadcn icon Button (40px target).
- The byrå-consultant shell and the stale-cookie shell rendered no banner,
so "every signed-in user" was not true. The banner is now computed once,
before the shell branches, and mounted in all three.
Refs #2463
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs9PfHL7KpdidvUdxVkHXF
* fix(dashboard): system notice deadline requires a UTC offset
A date-time without Z or a numeric offset parses as local time, which is
UTC on Vercel and the operator's zone locally, so the same value would
mean different instants. Reject it instead (CodeRabbit on #2464).
Declined: scoping the dismissal key by user id. The notice is about the
system, not the account; per-browser dismissal is the sandbox banner's
semantics and keeps identity out of layout chrome.
Refs #2463
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs9PfHL7KpdidvUdxVkHXF
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
a84d2723e0 |
feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import (#2458)
* feat(import): keep the source system's #BTRANS/#RTRANS correction history at SIE import A verifikat migrated from Fortnox/Visma lost the trail of what had been corrected in the source system: the parser skipped #BTRANS (struck lines) and #RTRANS (lines added by a rättelse) and nothing else read them. The final state is still built from #TRANS only, exactly as SIE 4B prescribes (#RTRANS is always twinned by an identical #TRANS, so summing all three double-counts, #63). The two history record types now ride along the voucher as `corrections` and land, inside the same atomic import transaction, as one journal_entry_rattelse_log row per corrected voucher with source='sie_import', the file's sie_import_id and the SIE `sign` (who corrected in the source system; SIE carries who, never when). Why the problem occurred: the March fix for double-counting chose "skip" over "keep aside" because nowhere existed to keep the history. The inline rättelse log (July) created that place, and every reader of it (verifikat page, "Rättad" marker, behandlingshistorik, full archive) already renders struck/added snapshots, so the history now flows through one table. What was removed or simplified instead: no new table, no per-import toggle, no fifth RPC parameter (sie_import_id travels inside each payload entry so the (uuid,uuid,uuid,jsonb) signature, grants and statement_timeout stay put and PostgREST sees no overload). The parser's three identical TRANS/RTRANS/BTRANS field parsers collapsed into one helper; the TRANS-only ledger path is byte-for-byte the same. Why this over the proposed shape: the reporter suggested an own table or column. A separate store would need its own readers, RLS, archive classification and behandlingshistorik wiring; the rättelselogg already has all four. Storing history in sie_imports.migration_documentation was rejected as aggregate JSON that no per-verifikat surface reads. Import-sourced log rows survive undo/replace like every other log row (no FK on purpose); a re-import writes fresh rows against fresh entry ids. Parser also warns when an #RTRANS is not followed by its identical #TRANS twin (a spec violation that would silently drop a line from the final state) and the record-type comments now match the spec wording. Migration 20260909132618: three nullable/defaulted columns + CHECKs on journal_entry_rattelse_log, sie_correction_snapshots() helper, import_sie_journal_entries body verbatim plus the history insert. No backfill; existing imports and log rows untouched. Fixes #2427 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn * fix(import): verify the SIE import id before it becomes provenance, keep per-line signatures Review findings on PR #2458, one pass: - Superagent P2: import_sie_journal_entries stored the caller-supplied sieImportId as WORM audit provenance without checking it. The RPC now requires the id to be one of the importing company's own sie_imports rows and fails closed (42501, whole import rolled back) on a foreign or fabricated id. pg-real test added. - Compliance review: the voucher-level external_signature collapsed distinct correctors per line. Each struck/added snapshot now carries its own SIE sign (importer + sie_correction_snapshots), the summary column stays as the first one. - Compliance review: created_at on imported rows is the import moment. Behandlingshistoriken now says so in the event details instead of leaving it implicit (the verifikat page already avoided a date). Migration file is unshipped (not on main); staging re-applied under the same version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2FcXNv8qRp4GaXCtzEdyn --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9a8291f454 |
feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457)
* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it Resultatrapport and dimension-pnl filtered account 8999 out and printed a computed result row, so a user who books or imports the omföring of årets resultat by hand saw huvudboken and the account-level report disagree. Why it occurred: the filter was copied from the formal Resultaträkning, where it is right (ÅRL's uppställningsform has no 8999 line). In the operational report it hid a real balance. Our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances that exist are manual or SIE-imported ones, exactly the case the report suppressed. What was removed: the exclusion itself, in both operational reports, so they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat" to match the UI and PDF. Beräknat resultat now reads zero after such an omföring, the Fortnox/Visma resultatrapport convention. Why this and not the proposed shape: the user asked about Resultaträkning, which stays as is on purpose. The bigger version (Stage 2 of #1051, showing the bokslut verifikat via exclude-final) would zero every row of a closed year given our closing-entry shape and is a separate decision; recorded in DECISIONS.md. Fixes #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t * test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning The cross-surface agreement test claimed the two operational reports are identical; after #2455 they differ by exactly a booked 8999 omföring, and the fixture had no such row so the invariant went silently false. Pin the gap explicitly, and note in DECISIONS.md that this supersedes the 2026-07-29 same-profit line. Refs #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e996d70955 |
feat(settings): rename learned counterparty templates (#2454)
* feat(settings): rename learned counterparty templates A user asked why a learned template under Inställningar > Mallar can be deleted but not renamed. Nothing legal or ledger-shaped blocks a rename; the one real obstacle was that the learn path keys templates by the normalized bank description, so a renamed row would stop receiving re-approvals and a duplicate would appear under the old key. Why it occurred: counterparty_name doubles as display name and as the learn/upsert key, and the only write path for it was the learner. There was no rename because every later approval would have forked the row. What was simplified instead of added: no display-label column, no new table, no migration. The rename moves the old key into counterparty_aliases, which the matcher already checks first, and the learn lookup (findTemplateByKey) now resolves name-then-alias so re-approvals and SIE re-imports land on the renamed row. Why this over the proposed shape: a separate label would have kept the key untouched but added a second name field for users to reason about; renaming the key with an alias trail gives the user exactly what they asked for with one fewer concept. Duplicate names are refused with 409 (active twin) or the invisible soft-deleted twin is removed (inactive). Fixes #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): resolve the normalized-name match tier through aliases after a rename Skeptic refutation on 819894559: the alias tier compares raw lowercased bank descriptors, so the normalized key a rename pushes into aliases ("spotify") never matched there, and the name tier only knew the new label ("musik"). A renamed template kept learning through findTemplateByKey but was never proposed again for the merchant it was learned from. nameMap now also resolves aliases, with a real counterparty_name always winning over another row's alias. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq * fix(bookkeeping): canonical name beats a borrowed alias; unique-name race returns 409 Review findings on #2454: - The alias tier ran before the name tier, so a bank line that is exactly another template's canonical name could resolve to a row holding that string as a rename alias. Aliases claimed by a different template's counterparty_name are now skipped when building the alias map. - The PATCH twin check and the update are separate statements; a learn or a concurrent rename between them surfaced as 500. Postgres 23505 on the update now maps to the same 409 as the pre-check. Refs #2453 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgYn5GEp4N5Dxjc1S9Ljbq --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
bb28968151 |
fix(import): chunk SIE account creation and close the import row on every exit (#2451)
* fix(import): chunk SIE account creation and close the import row on every exit
A full-BAS Bokio SIE file creates 1 200+ chart_of_accounts rows in one
INSERT. PostgREST runs it under the authenticated role's 8 s
statement_timeout, and with four row-level triggers plus the RLS WITH
CHECK that single statement measured 6.5 s to 8.2 s on prod: it was
cancelled for one company and passed for the next (2026-09-09).
- syncMappedAccounts inserts in chunks of 100 rows (INSERT_CHUNK_SIZE),
so every statement stays an order of magnitude inside the limit. A
failed chunk leaves the earlier ones committed; the next attempt reads
the chart again and inserts only what is still missing.
- executeSIEImport closes its pending sie_imports row in a finally
block. Every early `return result` after createPendingImportRecord
(account sync failure, missing fiscal year, overlapping import,
vouchers outside the year) used to leave the row 'pending'. That row
holds the (company_id, file_hash) slot in the partial unique index,
so a retry inside the five-minute cleanup gate failed on the index
instead of on the real error.
- The slot-held message no longer names "gnubok", an "Ersätt import"
button the import history has never had, or Fortnox; it says the
same file is being imported or was interrupted moments ago and to
retry in a few minutes. The thrown-error prefix is Swedish
("Importen misslyckades:") and the two undo hints name
accounted_undo_sie_import.
Tests: chunk sizes and first-failing-chunk behaviour in
account-sync.test.ts; failed-row finalize on two early exits and the
new slot message in sie-import.account-names.test.ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBTRraVXkCnx93Pa9wxJ6G
* fix(import): write account chunks as ignore-duplicates upserts with an exact count
Review follow-up on #2451. A plain INSERT per chunk still had the old
race: a concurrent import (or the replace flow) creating one account
between our read and write raised a duplicate-key error that the code
swallowed as success, while PostgREST had rolled back the whole chunk,
so every other account in it was silently missing.
Each chunk is now an upsert with onConflict (company_id, account_number)
and ignoreDuplicates, selecting the landed rows: the race becomes a
skipped row, `created` counts exactly what was written (also across a
mid-loop failure, which the compliance review flagged), and the
"duplicate" string special-case is gone.
Tests: conflict-skipped row not counted; a race yields no error; a
failing later chunk reports the rows the earlier chunks committed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBTRraVXkCnx93Pa9wxJ6G
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
fc2d78a7c4 |
feat(onboarding): the orgnr step suggests companies as you type (SCB search, TIC on the pick) (#2452)
* feat(onboarding): the orgnr step suggests companies as you type, SCB search, TIC on the pick Most people do not know their organisationsnummer. They left the onboarding for allabolag, searched their company name there, copied the number and pasted it back. #2421 let the field take a name, but only on Enter and behind a screen that still said "organisationsnummer", so the detour stayed. Now the field suggests companies while a name is typed (name, orgnr or "Enskild firma", city; arrow keys or click to pick), the pick fills the company like a typed orgnr, and the screen says "Vilket företag är det?" with "Företagsnamn eller organisationsnummer" as the placeholder. Why the problem occurred: the one identifier the step asked for is the one the user is least likely to remember, and the free-text path added in #2421 was invisible (copy unchanged) and had to be guessed (Enter only), because the only search index behind it was TIC, whose Lens budget cannot take a call per keystroke. What was removed or simplified: nothing is stored and no new state model: a picked suggestion is an ORG_SUBMITTED with prefill, so the existing LOOKUP_RESULT transitions (found, not found, disabled, error) decide the step exactly as for a typed number. SCB's name search already existed for the parties picker; it gained one option (sole traders) instead of a second client. No rate limiting anywhere, per the founder. Why this shape: SCB's företagsregister is free and already configured for the parties picker, so search-as-you-type costs nothing while typing; TIC runs once, on the pick, as it always did on Enter. TIC per keystroke was rejected (3000/month). SCB alone was rejected for the pick because it knows no F-skatt, VAT registration or fiscal year. The Enter path and the chip row from #2421 stay as the fallback when no row is picked. Sole traders are offered (they are half the users) but their row names the form and never prints the personnummer, and the field shows the company name after a pick for the same reason. Changes: - app/api/company/search: GET ?q= over the SCB client with sole traders included, top 6 rows plus a truncated flag; requireAuth() (no company yet), 400 for short or numeric q, 503 without SCB credentials, 502 when SCB does not answer. - lib/parties/scb/client.ts: searchByName(query, { includeSoleTraders }), legalFormCode on every candidate; the parties picker is unchanged. - lib/company-lookup: CompanySuggestion, COMPANY_SUGGEST_MAX, fetchCompanySuggestions (503 is disabled, everything else error, never throws), toCompanySuggestion (SCB legal form 49/10/61 into the TIC vocabulary mapSetupEntityType reads). - lib/onboarding-journey/reducer.ts: SUGGESTION_PICKED (orgnr, name and form as prefill, lookupPending; lookupRan stays false until TIC answers). - components/onboarding/journey: 300 ms debounced SCB search with abort of the superseded request, listbox under the field (combobox ARIA, arrow keys, Escape, Enter picks the highlighted row, otherwise the Enter path), copy switches with companySearchEnabled or ticEnabled; both journey pages pass isScbConfigured(). - messages sv+en: five strings. Tests: route (401, 400 short, 400 missing, 400 numeric, 503, happy with a sole trader, cap at 6, flood, 502); fetchCompanySuggestions (every outcome); toCompanySuggestion; reducer (pick equals typed orgnr after TIC, TIC overrides prefill, TIC off keeps the AB past form and name, unmapped form falls to the picker, sole trader confirms the name, replaces a previous orgnr, ignored off-step); SCB client sole-trader option. Fixes #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi * fix(onboarding): the suggestion list stays visible and stands alone (skeptic on e56eb242c) Three independent refuters on the frozen commit; every refutation that stood is fixed here. - The listbox was position: absolute inside the field, but the step scrolls (.jny-qstep is overflow-y: auto), so the list was clipped to the first row and mouse picks were unreachable (measured in headless Chrome). It now renders in flow under the field, where the chip row from #2421 already lives. - After Enter on a name (the #2421 path), SEARCH_RESULT flipped lookupPending back and the debounced effect refetched SCB, laying the listbox over the chip row or next to the nomatch note. The effect is now quiet while searchHits is non-empty and for text the user already confirmed (Enter or a pick), until the text changes. - The "many matches, type more" hint only rendered inside the list, so the flood case (SCB counts over 100 rows and sends none) showed nothing. The hint now renders on its own for that case. - app/companies/new-client (byrå adds a client) renders the same journey and now passes companySearchEnabled like the other two pages. - A stale mouse highlight could commit a row from the previous text on Enter: typing resets the highlight. - Any 503 switched the picker off for the session; only the route's own SCB_NOT_CONFIGURED does now. - NOTFOUND_EDIT / CEASED_EDIT dropped only the number and kept the abandoned pick's name and form, which a later TIC error path would have written into the company. Both now drop name and form too, unless they came from BankID's CompanyRoles prefill, which is not about the number. Not changed, recorded: a sole trader picked from SCB whom TIC does not know lands on the "no company on that number" step with the name in the field; the flow continues with the SCB name prefilled. The search JSON carries the personnummer of sole-trader rows to the authenticated browser (the row prints "Enskild firma"), same class as #2421's Enter search; flagged to the founder. Refs #2448 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNDuYBHVu172tesKfJmcmi --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cb9eedd7f2 |
fix(bank): unchecked accounts yield their bokföringskonto to a checked one in the picker (#2449)
Unchecking the wrong bank account and putting the right one on 1930 answered 400 "Flera bankkonton kan inte bokföras på samma konto": the collision pass counted every stored account as a claim, the picker hides the ledger dropdown for unchecked rows, and disconnect plus reconnect re-claims the same cash_accounts rows by IBAN. No route out (support case 2026-09-09, two 400s on the route in the Vercel logs). Checked accounts stay hard claims (duplicate and foreign-live = 400). Unchecked accounts hold their ledger as a soft claim: kept and mirrored with enabled=false unless a checked account wants it, then they yield and lose the prefill. Contested rows (this connection's row for a yielded or moved account, another connection's row for an account unchecked there) are demoted to manual in one update before the mirror, so upsertFromPsd2 promotes the holder in place and row ids, transaction links and the is_primary flag on the 1930 row survive. The same pass makes two checked accounts swapping ledgers work, which previously tripped the unique constraint in both upserts and was swallowed. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
6ea92f3152 |
feat(zettle): sync paid purchases into webshop_orders (#2445)
Community PR #2416 by @olofpinzke, adopted and finished by maintainers (rebased so every commit is signed). Why the problem occurred: no Zettle integration; POS sales only reached the books as bank descriptors while Woo/Shopify already had order underlag via webshop_orders. The contributor's version also failed at the database (platform CHECKs listed only woocommerce/shopify), which the mocked unit tests never saw. What was simplified: reused the Orders/book/invoice path instead of a new inbox; Finance API payouts/fees deferred. Sales the one-account, revenue-per-rate model cannot book (split tender, gift cards, tips) import unbookable with a "bokför manuellt" title instead of guessing accounts. Reset parity uses the rename-and-wrap pattern instead of re-issuing the reset body. Why this solution: per-purchase rows give the radunderlag BFL verifikat need and the bulk-book path exists; daily kassarapport aggregation and Finance API fees/payouts are the follow-up (DECISIONS.md). Skeptic-refuted paths fixed before merge: concurrent refresh-token rotation (sync claim), cron offset paging (candidate snapshot), platform CHECKs, writer-role gate, migration-reset parity, white-label return origin re-validated at callback, VAT net from product rows. Not live until ZETTLE_CLIENT_ID / ZETTLE_CLIENT_SECRET / ZETTLE_CREDENTIALS_ENCRYPTION_KEY are set on Vercel and a Zettle developer app is registered with the callback redirect URI. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WtYqzKPoTSRHskYYdf7MwB |
||
|
|
500806f001 |
fix(agent): the assistant reaches earlier räkenskapsår: period resolved from dates, years listed in the grounding (#2436)
Part 3 of #2185. A user with several imported years concluded the assistant "only reads the period I am standing in". Nothing restricted it: the report tools defaulted to the most recent fiscal period when no period_id was given and then rejected any from_date/to_date/as_of_date outside it, and neither the snapshot nor the chat identity block told the model which years existed or how to address them. - extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date hint; without period_id, a date in the call resolves the fiscal period that contains it (findFiscalPeriodContaining, company-scoped), and a date no period covers fails with the company's span instead of the latest year's bounds. Income statement (from_date or to_date), balance sheet (as_of_date) and dimension P&L (to_date) use it. The range guard is unchanged. No schema change: tools/list sits at its token ceiling. - lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on addressing an earlier year, shared by the single-call snapshot and the streaming chat's always-on identity block. - lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's period_id, one call per year, and say which räkenskapsår the answer covers. Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
20925f6c65 |
feat(worklist): the next Skatteverket payment with bankgiro, OCR and due date under Att göra on Hem (#2435)
Part (a) of #2187. A twelfth worklist category, skattekonto_payment_due: the earliest upcoming skattekonto charge whose sum exceeds the last synced saldo, computed once in lib/worklist (server-side twin of the /skattekonto page's Nästa dragning math) and rendered as one Betala row on Hem with the shortfall, bankgiro 5050-1055, the OCR reference and the due date. The row appears only when money has to move: a saldo that covers the charge yields nothing, and no upcoming charge yields nothing. Ignored rows take part, since Skatteverket draws them regardless of our flag. Without a balance snapshot the full charge is the amount. Without an org number the row keeps its bankgiro and date and drops the OCR. No table, route or migration: /api/worklist/counts picks the category up through getWorklistCounts, and Hem passes the computed row into the same options wave as the expense payouts. Part (b), a betalfil for the skattekonto payment, stays a follow-up: the existing payment-file route is AGI-scoped. Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a7dcaac6ad |
feat(kpi): monthly revenue, expenses and result table under Nyckeltal, toggle in Anpassa (#2433)
The KPI payload has carried income, expenses and net per month since the aggregates RPC, but after the Recharts trend chart was dropped only the net column was rendered (the bars pane). A fiscal year's month-by-month sums were therefore fetched and never shown (#2196). - New components/kpi/KPIMonthsTable.tsx: full-width dry table (Manad, Intakter, Kostnader, Resultat) with the period totals as the last row, rendered between the panes and the cost story. Rows and totals come from the pure helper components/kpi/months-table.ts. - New preference showMonthlyTable (default true) on KPIPreferences: filled by mergeWithDefaults on read, accepted by the preferences route, sent whole by the dialog, required by readPreferencesBody. A boolean, not a KPI_DEFINITIONS id: stored kpiOrder arrays would hide a new id for every existing company. - One Switch row in the Anpassa dialog after the KPI list. - Reuses the orphaned kpi.trend_* keys; adds months_col_month, months_total and the two settings keys in sv and en. - Tests: helper rows/totals/inactive flags, defaults + merge, route accepts false and rejects a string; fixtures updated for the new field. Closes #2196 Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5e2498bc2f |
feat(transactions): pick several ROT/RUT begäran by hand for one Skatteverket transfer (#2431)
The manual invoice picker's ROT/RUT section handed over exactly one begäran, while the route, the settle service and the confirm dialog take a bundle since #2360. When the automatic set matcher refuses a transfer (two open begäran with the same amount, or more than four), the user had no way to build the bundle and was told to split the bank row. Each begäran row now carries a checkbox; ticking one or more shows the running sum against the bank row and a "Matcha valda" button that hands the set (largest first, the matcher's order) to the existing confirm dialog, which still refuses a sum that is off the row. A plain row click keeps the one-begäran path. Part of #2425 (the suggestion itself shipped in #2271 and #2360). Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d5373cd66c |
feat(invoices): ROT/RUT begäran status as a column and filter in the invoice list (#2434)
An invoice's begäran state (Att begära, Skapad, Uppladdad, Beviljad, Delvis beviljad, Avslagen) was only visible one invoice at a time or inside the payout dialog. The list now embeds the begäran behind each invoice, shows the state in a ROT/RUT column and filters on it through a third ContextPicker (?rotrut=), both gated on rot_rut_enabled or an invoiced deduction. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts. Normal states read as muted text; only a partial approval and an avslag get a chip. Closes #2426 Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9782f80db0 |
feat(invoices): offert to kundorder, the missing step in offert, order, faktura (#2442)
* feat(invoices): offert to kundorder, the missing step in offert, order, faktura "Skapa order" on an open or accepted quote creates a draft kundorder from its lines. The quote stays as the customer's accepted agreement (flips to quote_status accepted with a compare-and-set on the decision that was read); the order is delivered and invoiced, in full or in parts, from the kundorder page. Declined quotes are refused. Same action on the MCP side: gnubok_convert_invoice takes target 'order', staged under the existing convert_invoice operation type. Why the problem occurred: the proforma -> order conversion refused every source that was not a proforma, so the offert, which is what users actually send before an order, could only become an invoice. The product had both ends of the Fortnox flow (offert, kundorder) but no bridge. What was removed or simplified: no second service and no new operation type. The proforma conversion became the document conversion (lib/sales-orders/convert-to-sales-order.ts) with the quote source as a branch on the source update, mirroring how convertToInvoice already treats the two. The MCP surface is one tool with a target parameter rather than a sibling tool, which also gives proforma -> order the MCP surface it did not have. Why this shape: the sale must never exist twice. A quote with a live converted invoice cannot become an order (INVOICE_QUOTE_ALREADY_INVOICED), and a quote with a live kundorder cannot become an invoice a second time (new INVOICE_QUOTE_ALREADY_ORDERED: invoice from the order instead). A cancelled order or invoice frees the quote again. Rejected: cancelling the quote like the proforma path (hides the accepted agreement), a separate gnubok_convert_quote_to_order tool, and refusing expired quotes (the invoice path allows them behind a confirm; the order path does the same). Fixes #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxwavqBoG1HwFD5znkCGLv * fix(sales-orders): hold the one-sale-per-quote guard in the database and fail closed on a missing FX rate Skeptic refutations on the offert -> kundorder change: 1. An already-accepted quote could be converted twice concurrently (two orders, or an order and an invoice): the services' pre-checks are not serialized and the accepted -> accepted compare-and-set matches for every caller. Migration 20260908152555 adds a partial unique index (one live kundorder per source document) and two BEFORE triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa, so concurrent conversions queue and the second one sees the first. The services map the raised codes onto the same 409s the pre-checks use. pg-real test covers the index, both directions, reopen from cancelled, the member-session lock, and the concurrent pair on two connections. 2. createInvoiceFromSalesOrder booked a foreign-currency invoice with a NULL exchange rate when Riksbanken had none, which resolveSekAmount() then posts 1:1 as kronor. Pre-existing, but the quote now depends on the order path and the fail-closed quote -> invoice route is refused while an order lives. The order path now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE, like convertToInvoice. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(pending): describe the kundorder outcome when approving a convert_invoice staged with target order The approval dialog's consequence sentence was keyed on operation_type alone and promised a faktura with F-number for every convert_invoice. With target 'order' the commit creates a draft kundorder and books nothing, so the sentence now reads the params (skeptic refutation). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): lock the quote decision behind a live kundorder, run the guards as definer, name the offert on the order page Correctness skeptic refutations on the offert -> kundorder change: 1. A quote with a live kundorder could still be set to open or declined (dashboard route, v1, MCP): the decision guard only knew converted invoices. The dashboard then hid the re-accept button, so the quote was stuck as "Avböjd" behind a confirmed, invoiced order. Migration 20260908155231 extends invoices_quote_decision_guard to refuse leaving accepted while a live kundorder points at the quote (INVOICE_QUOTE_ALREADY_ORDERED); the three writers map the code. 2. The two source guards from 20260908152555 locked the quote row with a SELECT FOR UPDATE as the invoker. Under RLS that also applies the UPDATE policy, which admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. All three guard functions are now SECURITY DEFINER. pg-real test covers the non-active company and the decision lock. 3. The kundorder page labelled every source "Proformafaktura". It now loads the source document and shows "Offert OF-nnn" for a quote; the MCP field description and the type comment say proforma or quote. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp): keep tools/list under its token ceiling and refuse cross-company sources in the definer guards CI: the target parameter and two description edits pushed the projected tools/list payload to 60 502 tokens against the 60 500 ceiling; the same facts now fit in fewer words (ceiling unchanged). Superagent P2: the source guards run as definer since 20260908155231, so a source_invoice_id or converted_from_id pointing at another company's document would have locked and inspected that row. Both guards now require the source to belong to the row's company and refuse otherwise (SALES_ORDER_SOURCE_COMPANY_MISMATCH / INVOICE_CONVERT_SOURCE_COMPANY_MISMATCH), covered by a cross-company pg-real case. Migration 20260908155231 was re-applied to staging under the same version (never on prod). Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move the quote conversion guards to versions after main's 20260908164944 Main merged a later version while this branch was open; Supabase applies pending versions in order, so both files are renamed to fresh versions (20260908165000, 20260908165100) and re-tracked on staging under those. Byte-identical SQL. Refs #2224 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
32721b9f61 |
feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)
* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner Why the problem occurred: the draft marking was a boxed yellow banner in the page's top margin. It stayed out of the flow (#2369) but still read as UI chrome pasted on a document, and carried a two-line legal sentence that nobody reads on a preview. What was removed: the banner block, its three styles and the four legal sentences (sv+en). The draft state is now one word, bold, rotated -35deg at 14% opacity, centred on every page, the way a stamp marks paper. The download dialog (#2399) already explains why a draft is not a valid invoice before the file exists, so the PDF does not repeat it. Why this shape: rotation and opacity sit on a padded wrapper View so the word turns about its own centre and the Text keeps a plain type style. The overlay is absolutely positioned over the page box and `fixed`, so the document underneath previews pixel-identical to the final print; a test asserts the first row sits at the same y as on a sent invoice. BETALD and MAKULERAD banners are unchanged (separate concern). Fixes #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(invoices): darken the draft watermark so it survives a greyscale print Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on white, which a monochrome print or greyscale scan drops, and a numbered draft otherwise prints the FAKTURA title, its number and an OCR like an issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85% so neither extreme can creep back in. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): paint the draft watermark last so opaque boxes cannot cover it Skeptic refutation (correctness and regression, independently): the overlay was the first child of the Page. react-pdf paints children in document order and `fixed` does not hoist, so the customer box and the full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the word. On a two-page draft the last page, the one with totals, bankgiro and OCR, lost the word entirely. The overlay is now the last child of the Page, behind a single isDraftMarked flag that also keeps the cancelled > draft > paid banner precedence. A new test inflates the rendered PDF content streams and asserts the UTKAST glyph run comes after the last rectangle fill on every page, so the element tree alone can no longer pass while the paint order is wrong. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(invoices): note the English DRAFT label in the download-decision comment CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an English document says DRAFT. Refs #2437 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2d49a81508 |
fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts (#2439)
* fix(bookkeeping): negative item rows book on the opposite side, never as negative amounts A supplier-invoice item with a negative line_total (an öresavrundning row on 3740, a rabatt row) was copied straight into debit_amount, producing a line like "3740 debit -0.25". The entry balances arithmetically, so no trigger fired, but the verifikat page renders only positive amounts: the row showed empty and the visible debits (20 056,25) disagreed with the summa (20 056,00). Prod holds 14 such lines: 12 supplier registrations in 3 companies, 1 customer invoice (3004 credit -0.50), 1 storno mirroring a bad original. Why it occurred: the "one non-negative side per line" invariant lived nowhere. Zod allows negative items (they are legitimate), the engine only checked balance, and journal_entry_lines had no CHECK. Any producer that aggregates user rows could repeat it. What was removed or simplified: no new state. The privately-paid supplier path already flipped negative buckets to credit; that rule is now one helper (lib/bookkeeping/line-side.ts) shared by the supplier registration, cash-method and privately-paid generators and by the customer-invoice per-rate generator. The credit-note generator stops swapping sides and takes |net|, since its inputs now arrive on the correct side. Why this and not the proposed fix: patching only the supplier generator leaves MCP, templates and future producers free to repeat the class, and rejecting negative items at input would break real rabatt/avrundning rows. So the sign is fixed at three levels: producers flip the side, the engine refuses negative amounts before any write (JOURNAL_LINE_NEGATIVE_AMOUNT, Swedish message), and a NOT VALID CHECK on journal_entry_lines rejects new rows regardless of the writer. reverseEntry swaps on the net so a legacy negative line stornos into a well-formed line before the data repair runs. The 14 existing prod lines are repaired by a separate founder-approved SQL (flip to the opposite column, net unchanged); VALIDATE CONSTRAINT follows in a later migration once prod reports zero offending rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMJvTFitzKQYuv7ABUVFj9 * fix(bookkeeping): anchor foreign-currency 1510/1930 on the net of the revenue lines; flip salary buckets by side Skeptic findings on ab119d6ed: 1. A non-SEK customer invoice with a negative row (rabatt, avrundning on a separate revenue account) now lands that row on the debit side, but the 1510 (accrual) and 1930 (kontantmetod) anchors summed only credit_amount, so the entry was overstated by the row and threw "Verifikationen balanserar inte". Both anchors now use credit - debit. EUR test added for both paths. 2. Salary: arbetsgivaravgifter, semesteravsättning, pension and SLP buckets copied bucket.amount into debit_amount and the aggregated liability into credit_amount. A negative month (unpaid leave beyond gross) produced 7510 D -628,40, which the engine now refuses. Buckets and liabilities go through debitNatural/creditNatural so a negative month books 7510 K / 2731 D. Test added. 3. replaceOpeningBalanceEntry, the third engine write path, now runs the same non-negative guard as createDraftEntry and updateDraftEntry. 4. The credit-note comment claimed |net| is side-correct for every original; it is not for originals with a negative row (pre-existing, callers negate items with -Math.abs). Comment now states the actual behaviour and the known gap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(bookkeeping): map JOURNAL_LINE_NEGATIVE_AMOUNT to a structured 400; supplier anchors flip side when the invoice nets below zero CodeRabbit on #2439: - JournalLineNegativeAmountError was not registered in isBookkeepingError / bookkeepingErrorResponse, so the journal-entry routes would have returned a generic 500 instead of the structured 400 with code and details. Added, with a test. - The three supplier balance anchors (2440 on registration, the payment account under kontantmetoden, the liability account for privately paid invoices) were fixed-credit lines. An invoice whose rows net below zero (a leverantörskreditfaktura keyed in as an invoice) produced a negative credit there, which the engine now refuses. The anchors go through creditNatural so such an invoice books 2440 D, as a supplier credit note would. Tests for all three paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c091a7f28e |
fix(ui): clip the flexible list cell on the rows #2003 missed (#2284)
#2003 gave TransactionInboxCard overflow-hidden because its shrink-0 row markers cannot truncate: past the width that fits them the cell painted over the Belopp column instead of clipping. Six other rows carry the same `max-w-0 w-full` flexible cell with shrink-0 chips inside and never got the guard, so they still overlap the neighbouring column. Reproduced on /transactions with a Skatteverket row whose booking suggestion is long ("Bokförs mot 2731 Avräkning lagstadgade sociala avgifter"): the text runs straight through the amount. Seen in both Chrome and Firefox, so this is not engine specific; Firefox only reaches it sooner, because it ignores the max-w-0 cap on a td when sizing columns (CSS 2.1 10.4 leaves max-width on table cells undefined). Rows fixed: the skattekonto inbox row, both rows in the transaction history list, the verifikat list, the chart of accounts, the customer list and the salary run list. ChartOfAccountsManager's second flexible cell is left alone: its only child truncates, so it has nothing that can overflow. Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f787a2b3c |
fix(bookkeeping): send each missing-underlag lookup chunk once per URL (#2430)
* fix(bookkeeping): send each missing-underlag lookup chunk once per URL The "verifikat utan underlag" filter (/bookkeeping?missingUnderlag=true) failed with "Verifikaten kunde inte hamtas" on a self-hosted instance as soon as the candidate set passed one chunk of 150 ids. Why it occurred: resolveMissingUnderlagEntries issues four lookups per chunk. Three carry the id list once; the supplier_invoices lookup interpolated the same chunk twice into a single .or() over registration_journal_entry_id and payment_journal_entry_id. That URL alone crossed the 8 KB header buffer nginx/Kong ship with, so the gateway answered 414 before PostgREST saw the request. Hosted sits at roughly half of Cloudflare's 16 KB ceiling on the same query. The LOOKUP_CHUNK docblock acknowledged the doubling without sizing for it. What was removed: the runtime-built .or() string, the chunkInList helper and its uuid guard (the .in() array filter is injection-safe on its own). The supplier-invoice lookup is now two .in() queries, one per FK column, merged into the same set, so every request carries the chunk exactly once and the proxy limit stops being a dependency rather than moving. LOOKUP_CHUNK stays 150 and its comment is now true. The literal filter also leaves the phantom-column scanner's unresolvable budget. Two secondary defects from the same report: MissingUnderlagQueryError now carries the raw driver error as `cause` and the journal-entries route logs it, while the response keeps the Swedish text (the log used to say only "Nagot gick fel", hiding the 414). The "Visa saknade underlag" badge renders the total of the last successful filtered fetch and hides on failure, instead of borrowing the list count (0 on a failed first load, the whole ledger after a toggle). Alternatives: halving LOOKUP_CHUNK moves the wall instead of removing it. Pushing the list filters into the verifikat_without_documents RPC and deleting the TS mirror leaves one predicate instead of two, but moves search, series, date and sort into SQL; recorded in DECISIONS.md as the intended next step for the surface owner. Fixes #2395 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ * fix(bookkeeping): hide the underlag badge on network failure, log the bulk route's cause Skeptic findings on aacb17634. The badge contract is "no honest source, no badge": the non-OK branch cleared missingCount but the network-level catch (offline, aborted body, JSON parse rejection) did not, so a period or series change that failed at that level kept the previous filtered total next to the toggle. The bulk "Inget underlag kravs" route had the same log gap as the list route: it returned the mapped text without logging the driver error, so a gateway 414 on that path stayed invisible. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
b2b714f829 |
fix(invoice-inbox): make the extraction prompt fill invoiceDate on receipts (#2429)
Receipts came back without a date on 46% of items in the last 30 days (75% via WhatsApp), while supplier invoices lost it on 0.4% and the purchaseTime on the very same receipts was filled almost every time. The prompt described invoice.invoiceDate as a bare ISO date under the invoice block, right beside a purchaseTime rule marked "receipts only", and the model read the asymmetry as "invoice-only". Describe the field as the invoice date or, on a receipt, the purchase date printed on it, and add an explicit receipts rule. Pin both in the extraction test. No schema or data-shape change; already-extracted items are not touched. Claude-Session: https://claude.ai/code/session_015bTBgZrofpGCgSUfAKN2H3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
26e29f47bc |
feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) (#2423)
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1157ff1b66 |
feat(onboarding): the orgnr field also accepts a company name (#2421)
* feat(onboarding): the orgnr field also accepts a company name The journey's first question kept asking for an organisationsnummer, and people who do not know theirs by heart left to look it up. The same field now takes either: digits (with dashes or spaces) run the existing orgnr lookup unchanged; anything else with three or more characters runs a free-text name search against the same TIC index. One hit continues exactly as a typed orgnr would; several hits render as a chip row "Name / orgnr / city" inside the same question, and the pick applies the hit's already-fetched lookup result. No hits stays on the step with a note to refine or type the number. The screen, placeholder and hint are otherwise untouched; only the mobile keyboard changes from numeric to text. Why the problem occurred: the lookup was keyed on the one identifier the user is least likely to remember, while the provider index behind it is a full-text index that already answers names. What was removed or simplified: nothing new is stored. The TIC search document carries every field /lookup returns, so a name hit is mapped by the same mapper and a picked hit costs no second provider call. The reducer gained one shared "TIC answered" transition (applyLookupFound) that the typed-orgnr path, the single-hit path and the pick path all use, instead of three copies of the fact-to-settings mapping. Why this shape and not the proposed one: search-as-you-type autocomplete would burn the 3000/mo TIC budget in days, so the search fires on Enter only, like the orgnr lookup. Taking the top hit blind on several matches was rejected: name ranking is fuzzy and common names or sole-trader surnames would land on a stranger's company; a five-chip pick row is the smallest thing that keeps the user in control. The route answers 400 under three characters, 404 in the handler's own "Company not found" shape so the client's existing dispatcher-vs-handler mapping applies, and every TIC failure code maps through the same handler as /lookup. Fixes #2418 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW * fix(onboarding): reduce Lens registration numbers to the 10-digit form for name-search hits Skeptic pass on 1d70716a8 (issue #2418). A sole trader found by name got Lens's 16-digit registration number (century-prefixed personnummer plus a 4-digit serial) stored as org_number; createCompany refuses anything normalizeOrgNumber rejects, so the journey dead-ended at the last step and the returned orgnr step could only shake. The typed-orgnr path never stored Lens's number, so this was the first place it reached settings. - searchCompaniesForLookup derives orgNumber through the new lensRegistrationToOrgNumber (16-prefixed 12 digits and the 16-digit enskild-firma form reduce to the 10-digit key; hits that do not normalize are dropped, never dead-ended). - Sole-trader chips show "Enskild firma" and city instead of the number, which is the owner's personnummer. - The name path resets the duplicate note on submit, so an earlier orgnr's "you already have X" no longer sits above the chip row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW * fix(onboarding): keep the typed name in the field after a search pick Compliance swarm on PR #2421: writing the picked hit's org number into the visible input printed a sole trader's personnummer in plain text on Back, the one thing the chip row masks. The field now keeps the name the user typed; Back re-searches it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKHTqmnvBJAgdW4V7wZsAW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5987523a25 |
fix(migrations): re-issue the 12xx label backfill inside an explicit transaction block (#2422)
* fix(migrations): re-issue the 12xx label backfill inside an explicit transaction block 20260908113353 (PR #2419) used a bare LOCK TABLE. The CI replay (psql -f per file) and the Supabase branch runner execute migration statements in autocommit, so Postgres refused it ("LOCK TABLE can only be used in transaction blocks"): pg-real, pg-upgrade and tool-pg went red on main and prod's migration queue stopped at that version, which also blocks 20260908130127 (PR #2420). Prod never recorded 113353, so the file is replaced rather than edited: same statements wrapped in BEGIN/COMMIT (precedent 20260513140000), new version 20260908120449. Applied and pg-tested on staging. Refs #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd * docs(decisions): migrations wrap transaction-only statements in BEGIN/COMMIT Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e85eac317c |
feat(arsredovisning): manual override for medelantal anställda (Not 2) (#2420)
* feat(arsredovisning): manual override for medelantal anstallda (Not 2) Why the problem occurred: the ÅRL 5:20 § note was derived only from the employees table, and most aktiebolag that book salary never create a Löner employee record (hand-booked salary, SIE import, migrated history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have no employees rows, so their note reads "inga anställda". The reporting company had one row created the same day with a start date halfway through a July-June year: 181/365 = 0.5 FTE rounds to 0. What was removed or simplified: nothing removed. One resolver (resolveMedelantalAnstallda: override, else FTE average) now feeds the K2 note, the K3 note and the iXBRL fact, so no reader can pick a different number. The override sits on arsredovisning_narratives next to the other ÅRL 5 kap. disclosures and rides the existing narrative GET/POST route, service and page save. Why this and not the proposed one: the request asked support to "enable override of Not 2" as free text. A whole number keeps the statutory sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free text would allow a non-compliant note. Rounding 0.5 up globally was rejected (changes every company's note silently, does nothing for companies with no employees rows), as was backdating the hire date (fixes one company, misstates the fact). The iXBRL input also reads the previous period's override so the jämförelseår column shows what last year's document showed. Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and is applied and tracked on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD * fix(arsredovisning): size-threshold metrics use the medelantal override too Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read the FTE average from the employees table, so the ÅRL 1:3 § större- företag test and the K2 relief thresholds could disagree with the figure Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no employees rows and an override of 60 both years, balansomslutning over 40 MSEK, would have validated as K2-eligible while its own document said 60 employees. Fix: the metrics resolve the employee figure the same way the note does (current period override from report.disclosures, previous period via getMedelantalOverride on that period's narrative row). previous_period on ArsredovisningData now carries the period id so the lookup needs no extra fiscal_periods read. The iXBRL employees-error fallback keeps the previous period's override instead of blanking the jämförelseår. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
59d5b7b366 |
fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts (#2419)
* fix(bookkeeping): name 1249/1259/1269 after their BAS 2026 free heads, drop retired 12xx sub-accounts A sole trader booking a tractor activated 1240 and 1249 from the account picker and got a machinery head labelled "(Fritt konto för Maskiner och andra tekniska anläggningar)" next to a contra account labelled "Ack. avskrivningar på bilar och andra transportmedel". Why it occurred: BAS 2026 restructured kontogrupp 12. Bilar and datorer moved under 1210 (för produktion) and 1220 (ej för produktion), and 1230/1240/1250/1260 became free heads. The catalog in lib/bookkeeping/bas-data/ followed for the heads (#463) but kept seven sub-accounts the official chart no longer has (1241, 1242, 1249, 1251, 1259, 1261, 1269) with their pre-2026 names. Every picker activation of a 12xx contra account therefore produced the contradiction; prod carries the 1240/1249 pair in 173 charts, 1250/1259 in 153 and 1260/1269 in 78. What was removed instead of patched: 1241, 1242, 1251 and 1261 leave the catalog entirely (bas.se BAS 2026 v2 has no such accounts; the SIE mapper already self-maps unknown sub-accounts by number). 1249/1259/1269 stay because the asset module's vehicle and computer defaults and 31 live assets in prod depend on them; they are renamed after their heads so the pair reads as one thing. Why this and not the proposal: the reporter asked for 1249 to be renamed to "ack. avskr. maskiner", which fixes one number and leaves 1259/1269 and the four retired asset accounts contradicting their heads. Dropping 1249/1259/1269 and moving the asset defaults to BAS 2026 (1226/1224 on 1229) is the right long-term shape but changes what a new vehicle or computer asset books to; that decision is the founder's and is tracked in #2414. The migration renames a contra account only when its name is byte-identical to one of the two catalog literals AND the company's head carries the BAS 2026 free label, so old-BAS imports (1240 "Bilar och andra transportmedel") and every user rename stay untouched. Applied and pg-tested on staging. Fixes #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd * fix(bookkeeping): skip 12xx contra accounts with journal lines in the label backfill Skeptic refutation: lib/import/account-sync.ts creates missing accounts with the catalog name when the SIE #KONTO names are not carried, so an old-BAS vehicle chart can hold the exact free-head + bilar-contra pair with years of depreciation booked on 1249 (8 such charts in prod). The backfill now also requires that the contra account has no journal lines: a label with history is the user's to change. Migration re-issued under a fresh version, applied and pg-tested on staging. Refs #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd * fix(bookkeeping): lock journal_entry_lines while the 12xx label backfill checks history CodeRabbit (Major): under READ COMMITTED a posting could commit between the NOT EXISTS history check and the rename. A SHARE lock on journal_entry_lines for the migration transaction makes the two atomic; inserts wait milliseconds, reads are unaffected. Migration re-issued under a fresh version, applied and pg-tested on staging. Refs #2413 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NXSuVejFCvRDyNXF1otEPd --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2303f75a7b |
fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage Why the problem occurred: the supplier register was written in three spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored whatever the caller sent, the AI extractor emits bare digits) while matchSupplierByIdentity compared raw strings with .eq(). The canonical rule existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey, the extractor's toOrg10) and nowhere on the path that decides a match, so every AI-extracted invoice from a hyphen-registered supplier missed the strongest key and fell to exact-name matching. Prod holds 1738 hyphenated rows against 493 bare ones. What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10 of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two other copies. The matcher scans the company's suppliers with an org_number and compares keys, the same shape as its vat_number branch, so rows written before the backfill (and self-hosted instances that never run it) match too. CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier schema store the key; the form renders it through formatOrgNumberDisplay. A backfill migration strips the formatting from existing rows, skipping migration-reset source companies. Why this and not the proposed one: the issue's third layer (CHECK plus a unique index) would fail to create on prod, which holds 94 duplicate (company_id, key) groups across 18 companies, one of them 124 rows under a single placeholder-looking number; that needs a merge decision first and is filed as #2404. Rejecting anything that is not 10 or 12 digits on write was also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL, FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is canonicalised and anything else is stored as typed. Luhn stays lenient on suppliers because two rows with the same mistyped number are one supplier and parties is Luhn-strict at promotion already. Fixes #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key Skeptic pass on the previous commit. Three refutations, all confirmed: 1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to 6012579001, another company's identity, on every write path and in the backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A Belgian BE0123456789 lost its country letters the same way. The key now strips only hyphens and spaces and unprefixes 12 digits only behind 16/18/19/20; everything else is null, stored and compared as typed. The migration carries the same rule. 2. The supplier list search, the v1 ?search= filter and the list column all used the raw stored value, so a user searching 556677-88 after the backfill found nothing. Both searches now compare without separators and the column renders XXXXXX-XXXX. 3. Storage was not canonical on every path: the CSV import and the provider migration orchestrator wrote as typed and keyed their re-sync dedup by the raw value, so a Fortnox re-sync sending 556677-8899 would have duplicated the now-bare row. Both write and key through orgNumberKey. Also: the matcher scans live suppliers only, so a register holding an archived hyphenated row next to its live replacement resolves to the live one instead of whichever id sorts first. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical CodeRabbit findings on PR #2405, all verified against the code: - The supplier list rendered through formatOrgNumber, which strips letters and would show BE0123456789 as 012345-6789; it now uses formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone. - The CSV import dedup fell back to digits-only, so BE0123456789 and FR0123456789 collided; the fallback is now the value as typed, in both the parse preview and the execute route. - The provider migration's supplier-invoice stub map was keyed by the raw provider value while the stored row was canonical, so 556677-8899 and 5566778899 on two invoices produced two stubs; the key goes through orgMapKey like the other maps. - v1 response examples show the stored 10-digit form; the request example keeps the hyphenated input. Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag * docs(api-skill): regenerate suppliers reference for the canonical org_number example Refs #2391 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
eea410c42b |
fix(bookkeeping): correctEntry moves bank anchors to the correction; deleting the correction returns them (#2406)
* fix(bookkeeping): correctEntry moves the original entry's voucher links to the correction Why the problem occurred: a bank row has two anchors, the pointer column (transactions.journal_entry_id) and the transaction_voucher_links junction (bulk-book writes a bank_line row beside the pointer for N=1 and as the only anchor for a samlingsverifikat with N>1). correctEntry re-pointed only the pointer, so the reversed original kept its junction rows and every junction reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC, the reconciliation bridge) went on treating the row as anchored there. A later storno of the correction released the pointer while the stale link kept the row out of Att bokfora: the split #2061 fixed on the storno path, reproduced on the correction path. Prod holds 7 such links in 3 companies. What was removed or simplified: nothing new is added to the data model. The relink helper now moves both anchors with the same predicate (company and source entry), so the junction follows the pointer and one rule covers the N=1, samlingsverifikat, 1:N slice and residual shapes. A relink failure is surfaced on the result (transactionRelinkError) beside documentRelinkError instead of being logged and forgotten. Why this solution: the issue proposed deleting the original's junction rows. For a samlingsverifikat the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into the worklist; re-pointing keeps them booked against the live entry. A relink_entry_anchors RPC moving pointer and junction atomically was considered and left for later: it costs a migration plus pg test on a path that is already best-effort across five other statements, and the surfaced warning now makes a partial failure visible if one ever happens. Tests: unit cases on correctEntry for the junction update, its scoping and the surfaced warning; a pg-real suite that runs the two UPDATE statements as the correcting user against real Postgres for the N=1, samlingsverifikat, split-plus-residual and cross-tenant shapes (RLS, the writer-role gate and the immutability triggers do not block the move; role and allocated_amount survive it). Fixes #2364 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * fix(bookkeeping): deleting a correction returns its bank anchors to the original Found by the skeptic on the previous commit. Once the junction follows the correction, the two-step undo of a rattelse (delete the correction, last in series, then delete the storno, which restores the original to posted) cascaded the links away with the correction (FK ON DELETE CASCADE; the pointer FK is ON DELETE SET NULL). The restored original then explained bank rows nobody pointed at: is_business stayed true, the rows surfaced as bookable in Att bokfora and in bank reconciliation, and a second booking of the same movement was one click away. Before, the links had stayed on the original by accident and the undo happened to be clean. delete_last_voucher (migration 20260908095907) now moves both anchors back to correction_of_id before deleting a correction, the inverse of the move correctEntry makes. Releasing the rows instead would leave the same trap (the restored original still explains them), and a TS pre-step in the DELETE route is not atomic with the RPC's own guards. A link the original already holds (a correction made before the junction followed it) is dropped rather than duplicated. Everything else in the function is byte-for-byte 20260528120600. Applied to staging and recorded under the file's version. pg-real suite covers the N=1, samlingsverifikat, pre-existing-duplicate and plain-voucher shapes; the existing delete_last_voucher and document-immutability suites still pass. Refs #2364 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * docs(decisions): record the #2364 prod repair as planned, not done CodeRabbit on PR #2406: the entry read as if the seven-link repair had already run. It runs after merge on the founder's go and gets its own dated entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
25b969183c |
fix(invoices): wrap the Visa PDF handler so the click event is not read as options (#2403)
The review pass on #2401 gave previewPDF an options parameter but left the
button's onClick as a bare reference, so TypeScript saw a MouseEvent flowing
into { asDraft?: boolean } and the production build failed (Core Build and
the Vercel deploy on
|
||
|
|
4e8d649b2f |
fix(invoices): Ej skickade view and a prompt before downloading a draft-stamped PDF (#2401)
* fix(invoices): show finalized-but-unsent invoices as their own view and ask before downloading a draft-stamped PDF
Two user reports, one hidden state: an invoice that went through Granska
& skapa has an F-number but its DB status is still 'draft' until it is
marked as sent (and booked). The list lumped those rows under Utkast, and
"Ladda ner PDF" handed out the UTKAST-stamped render with no warning, which
users then mailed to customers.
Why it occurred: the status column carries two meanings for 'draft'
(unnumbered draft vs numbered, unsent invoice) and every surface decided on
its own how to read it. The list badge knew the difference ("Ej skickad"),
the tab predicate and the PDF download did not.
What was simplified: the tab predicate moved out of the page into
lib/invoices/invoice-list-tabs.ts as one function used by the rows, the
per-view counts, the status sections and the row badge, so the four cannot
drift. The ?status= alias parsing collapsed into the same module.
Why this and not the proposed shapes: a real 'issued' status in the DB
would touch MCP, the v1 API, reports and SIE for a distinction that
invoice_number already carries. Removing the UTKAST stamp from numbered
drafts would be wrong: an unbooked invoice is not issued. So the UI splits
the state (Ej skickade view, ?status=unsent, ?status=godkanda alias) and the
download asks first: "Bokför och ladda ner" (or "Markera som skickad och
ladda ner" for cash-method companies and offerter) runs the existing manual
mark-sent dialog and then downloads the issued document; "Ladda ner utkast"
still works. The manual mark-sent toast now offers "Ladda ner PDF" too.
Fixes #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
* fix(invoices): skeptic round on the draft-download prompt
Four refutations on 352d4bafc, all confirmed in code:
- Proformas (and any non-faktura) in an accrual company were told "Fakturan
är inte bokförd" and offered "Bokför och ladda ner"; mark-sent never books
a proforma. The label predicate is now issuesByBooking = booksOnIssue &&
isRealInvoice, shared with the existing primary button, which carried the
same "och bokför" promise on proformas.
- A partial mark-sent success (PDF archive, periodisering or delivery history
failed) still ran the chained download, and its toast evicted the warning
(one toast at a time). onSuccess now carries `partial`; the chained
download is dropped on a partial result, matching the toast action.
- Numbered följesedlar are stamped UTKAST too (pdf-template does not exclude
them) but the decision skipped the prompt. They now get the prompt; the
issue action is their own status flip, so updateStatus reports success and
the download is queued only after it.
- The download queue was a boolean bound to "whatever invoice is mounted";
the detail pager keeps the page mounted across ArrowLeft/ArrowRight, so a
step could download the neighbour or leave the queue armed. The queue now
holds the invoice id and is dropped when a different invoice is shown.
Refs #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
* fix(invoices): review pass on PR #2401
CodeRabbit, all confirmed against the code:
- The PDF preview (Visa PDF) bypassed the draft prompt; the browser viewer
has a save button, so an unwarned preview is an unwarned download. The
preview now runs the same decision; the prompt's draft button honours the
original intent ("Visa utkast" opens the viewer, "Ladda ner utkast" saves).
- updateStatus lost its isUpdating reset when both branches started
returning; moved to finally so a failed refetch after mark-sent does not
leave the page's buttons disabled.
- A cancelled credit note matched both the Kreditfakturor and Makulerade
views; the credit view now excludes cancelled rows like every other view.
Declined: the DECISIONS.md date (2026-09-08 is the local date the decision
was recorded; the bot compared against UTC) and the docstring-coverage
warning (not a repo gate).
Refs #2399
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZiqSg2v6XrtaFZyyRK88b
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
477b59453f |
fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string (#2398)
* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string
Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").
One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card
Skeptic findings on 4a30bb3f3:
- The repair migration would have aborted on prod: 110 of the 6,356 rows
belong to a migration-reset source company, whose transactions are
immutable by trigger (transactions_block_migration_reset_source_mutation).
Same failure as 20260903170000. Those rows are now excluded; nothing reads
the column back for an archived company.
- With the code description reaching the keyword tables as a string,
"Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
precedes withdrawal in both tables (and in the Connect mirror), matching
what TRAILING_PHRASES already says about the same phrase.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed
coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
fdcb7d937e |
feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle (#2397)
* feat(rot-rut): overview page, beslutsfil import, avslag reclaim, MCP list + settle Follow-up to #2239/#2360 for firms whose every invoice carries ROT/RUT. - /invoices/rot-rut: tiles (at Skatteverket on 1513, awaiting beslut, refused to book, ready to request) and one row per begaran with mark uploaded, cancel, download and "Bokfor nekat belopp"; the Fakturor button links here, ?rot-rut=1 still opens the file dialog. - Beslutsfil import from the UI through the existing import route. - Reclaim of the share Skatteverket refused: one voucher debit 1510 / credit 1513 per invoice (source_type rot_rut_reclaim), CAS-attached to the begaran and guarded by a partial unique index; the invoice reopens for the refused share via invoices.deduction_reclaimed_total, with the customer-share formula and its SQL twin gaining the same term. The payment dialog and bank match then settle the reopened remaining as a plain 1510 clearing; a booked kontantmetod invoice is proposed accrual- shaped so revenue is never recognised twice. Unknown per-invoice split of a partial beslut is refused, never allocated. - MCP: gnubok_list_rot_rut_payout_requests (search-only read) and gnubok_settle_rot_rut_payout (staged write, op settle_rot_rut_payout) sharing one pre-flight + settle with the dashboard match route. - Migrations 20260907140000 (reclaim state, source_type, INSERT guard), 20260907140100/140101 (pending_operations op type). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * chore(rot-rut): renumber migrations after merging main Main already carries 20260907143000 and 20260907150000, so the three rot-rut migrations move to 20260907160000/160100/160101 to keep the applied order monotonic (see memory: migration-version-collisions). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): close the reclaim gaps found by skeptics, CI and review Skeptic refutations (#2397): - payment-sync recomputes remaining with deduction_reclaimed_total, so a storno of a payment on a reopened invoice no longer strands the refused share (R1). - Reclaim refused while an invoice sits in a later live begäran (ROT_RUT_RECLAIM_INVOICE_REREQUESTED); the overview and the MCP list hide the action for the same case (C2). - A reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed (R2/C3). - Storno of the reclaim voucher syncs the invoices and the begäran back (rot-rut-reclaim-reversal.ts, hooked into reverseEntry) (R3). - A paid invoice with NULL paid_amount counts its customer share as paid (C4). Crediting an invoice with a reclaimed share is refused on the dashboard, v1 and MCP paths (R4). CI and review: - Build: custom-coded MCP errors via Object.assign, not codedError. - pg-real: column default for default_voucher_series_per_source_type re-stated with rot_rut_reclaim (20260907160200); the default test now re-applies the latest default migration. - Checks: accounted-api skill regenerated (journal-entries source types). - CodeRabbit/Superagent: per-item refused shares must reconcile with the request-level beslut; per-invoice reopen through the idempotent RPC apply_rot_rut_reclaim_invoice (20260907160300) with a resume path; update-stage settle failures keep the voucher id (failed_partial); Stockholm calendar date for the booking; existing-voucher tab uses the same proposal method; MCP stage checks bank_line junction rows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): carry the voucher id through the match outcome type; date the reclaim on the beslut - The shared match outcome now declares journalEntryId on update-stage errors, matching the settle service (Core Build TS2339 on 2d6cece1a). - The reclaim voucher is dated on the Swedish calendar day of Skatteverkets beslut (decided_at), today only when no decision date is recorded, and the confirm dialog states the date (Swedish accounting review: BFL 5 kap 6-7 §, datum for affarshandelsen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB * fix(rot-rut): reclaim RPCs validate the share and derive the invoice state; idempotent revert; v1 credit guard reads the column - apply_rot_rut_reclaim_invoice (20260907160400 replaces the 160300 signature) takes only the refused share, validates it against the locked item, request and invoice, and derives remaining_amount and status from the INSERT-guard formula (review: caller-supplied accounting values, CWE-862). revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher; the request link is cleared only after every leg. - v1 credit route projection includes deduction_reclaimed_total so the reclaim guard actually fires there. - Overview keeps "Bokfor nekat belopp" available while legs are pending (resume after a partial failure). - Match and settle routes attach journal_entry_id on update-stage errors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D9wvsGnvu5tHGqYnJnjnaB --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5d735097a1 |
fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only (#2393)
* fix(period): let klarmarkera close a migrated year whose native vouchers are balance-sheet only markPeriodClosedExternally decided "migrated" by asking whether the period had any non-imported verifikat at all. That was a proxy for the thing the guard protects (a bokslutsverifikat transferring 3xxx-8xxx, BFL 5-6 kap), and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930 D / 2081 K aktiekapital) after a failed SIE import. With the next year's IB already imported, the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all. The guard now asks the ledger the real question: are there lines on result accounts (BAS class 3-8) in the period? Id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. The refusal message names what was found and why the normal year-end is the remedy. The loaded årsredovisning view also gets the FyPicker in its header. The no-period branch auto-jumps to the remembered scope (or the newest year) before its own picker is ever seen, so a user whose scope pointed at the historical year had no way to reach the current year's årsredovisning except by changing the scope on some other page and coming back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q * fix(period): PR #2393 round 1: require next-year IB for balance-sheet-only klarmarkera, bound result range to class 3-8 Triage of the swedish-compliance review: - Balance-sheet-only native years bypassing the bokslut: partly real. The result transfer is not the only thing the normal year-end does; it also posts the next year's IB. A natively bookkept year with only balance- sheet vouchers (dormant AB, aktiekapital deposit only) could have been klarmarkerad and its balances would never have reached the next year. The leg now additionally requires the next period to carry IB already, which is exactly the shape where the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path. Without that IB the normal year-end works and stays the remedy, and the message says so. The suggested IB-correction tag on entries was not adopted: the ledger already answers the question, a tag would be one more concept to know. - Unbounded account range: real, cosmetic. Bounded with lt '9' so class 9 interna poster no longer count as result accounts. Storage format is the 4-digit BAS string every classifier in the codebase already relies on (isBalanceSheetAccount reads charAt(0)); the text compare is as robust as those. Tests: next year with IB passes, next year without IB refused, no next year refused, lt('account_number', '9') asserted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZjtjw5xnC2SVaEr5HqG8Q --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
57a5af1310 |
fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on (#2386)
* fix(woocommerce): return the wc-auth browser leg to the brand host the connect started on Sessions are per domain. A white-label user who started a WooCommerce connect on their brand domain was sent back by the store to the canonical app URL, where the return leg's initiator check found no session and bounced them to a foreign-branded login. The connect route now resolves the request host through the trusted-origin helper (brands-table validated, canonical on an unknown host or a failed lookup) and builds the wc-auth return_url on that origin; the callback_url stays on the canonical host because it is server-to-server and needs a stable address. The return route resolves its panel redirect base from the host it was reached on the same way. No stored origin column and no OTC handoff: the wc-auth return_url is free-form per handshake, unlike a registered OAuth redirect URI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * test(woocommerce): name the state-less return test for what it asserts, drop the dead app-url stub The return route now resolves its redirect base through the trusted-origin helper, so a brand-host hit can do one cached brands lookup; the test only ever asserted that woocommerce_connections is never touched, and now says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f047c3d7d1 |
fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user (#2373)
* fix(skatteverket): finish the BankID consent on the initiating origin, bound to the initiating user The Skatteverket OAuth callback answered NEXT_PUBLIC_APP_URL regardless of where the flow started, so on a white-label brand domain the popup's postMessage was dropped and the fallback redirect landed on the wrong origin without a session. On hosted, the initiator check from #2155 was bypassed by design because the registered callback host carries no app cookies, so a lured victim's BankID-authorised tokens could be stored under the user who started the flow. Flow state moves from six per-company extension_data keys to one oauth_flows row per flow (migration 20260907120000), consumed atomically. Hop 1 on the registered OAuth host consumes the state, stashes the provider code or error encrypted under a separate handoff id and 302s to the recorded origin; hop 2 there claims the handoff bound to that origin, requires the initiating user's session, re-checks membership and exchanges the code. Error pages keep the tab open. The self-hosted single-hop and the connector broker branch keep working. The hosted no-session exception, the legacy cookie-user fallback and the optional PKCE verifier are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): decide the callback hop by host, close the tab when the flow is unknown Skeptic findings on #2373. The hop comparison and the handoff claim used the request origin including its scheme, which Next derives from x-forwarded-proto; a self-hosted proxy that forwards Host without it (or rewrites Host to the upstream address) made every connect end in a state error. Hops are now compared by host only, and the handoff is claimed for the validated origin the host resolves to, scheme from configuration. Error pages answered before the flow row is known (unknown, expired or replayed state or handoff) post to a guessed origin that a brand opener never hears; they now close the tab so the panels' closed-tab watcher resets them instead of leaving Connect disabled. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * test(skatteverket): mock resolveBrandResultByHost for the merged login-redirect resolver Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): bind the initiator before the flow is spent Superagent P2 on #2373: hop 2 deleted the handoff before the session and membership checks, so a signed-out or wrong-user arrival burned a live consent. The finishing hop now peeks the row for its initiator, binds the completing session to it, and only then consumes atomically. A session-less arrival is sent to /login on the initiating origin and resumes into the same callback URL; a different user is refused with the row left claimable for the initiator. The handoff TTL is five minutes so a sign-in fits. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH * fix(skatteverket): check membership before the flow is spent, answer the callback page on a failed mint Second review cycle on #2373. Superagent: the company-membership check ran after the consume, so a revoked initiator burned the provider code on the way to being refused; it now runs inside the pre-consume binding. CodeRabbit: a failed handoff mint escaped as a framework error page the opener never hears; it now answers the callback error page on the initiating origin. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T1YDNadz81eWo94j115bhH --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
cb962fae88 |
fix(auth): resolve BankID confirmation and email-hook link hosts through the trusted-origin registry (#2380)
* fix(auth): resolve BankID confirmation and email-hook link hosts through the trusted-origin registry The BankID confirmation mail built its /auth/callback link from the raw forwarded host and protocol; it is the one auth link GoTrue's redirect allowlist never sees, since the link is minted here and sent through Resend. The Send Email hook followed GoTrue's redirect_to verbatim: the webhook signature proves who sent the payload, not that every destination in it should be followed, and the GoTrue allowlist is a hand-configured glob. Both now resolve the destination through lib/domains/trusted-app-origin like every other auth link (canonical, this deployment's own Vercel hosts, or a registered brands.domain). Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is taken from the RESOLVED host, so mail branding and link destination always agree. A brands-table read failure refuses instead of mailing a wrong-host link: the BankID helper returns step resolve_origin (signup rolls back, login re-send logs), the hook answers 500 so Supabase retries. Drops the proto parameter from the BankID helper; the resolver owns the scheme. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T * fix(auth): read the sender brand once, failure-aware, before minting or sending auth mail CodeRabbit: resolveTrustedAppOrigin could classify a brand host, then the separate resolveBrandByHost read for the sender could fail and return null, so a brand link went out with the platform sender; the BankID helper had already minted the magic link by then. Both sites now read the brand with resolveBrandResultByHost on the resolved host and refuse on a failed read for any non-canonical origin (BankID: step resolve_origin before generateLink; hook: 500 so Supabase retries). On the canonical origin a failed read is the platform sender either way, so mail still goes out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T * fix(auth): treat a credential-bearing redirect_to as untrusted in the email hook Superagent P2: URL.origin drops userinfo, so a redirect_to with credentials on a served host passed the origin comparison and was cloned into the auth link with the credentials still in it. No flow of ours sends one; the hook now rejects any redirect_to carrying username or password outright and links to the canonical /auth/callback with no next path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0189cGB2YxptqVxBLJ2RkB5T --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e0244b95a7 |
fix(woocommerce): require both verified keys and browser confirmation to activate a connection (#2375)
* fix(woocommerce): require both verified keys and browser confirmation to activate a connection The wc-auth callback alone used to flip a connection to active. Until the initiator's browser reached the return route the row was syncable, so an approver who never came back (closed tab, skipped sign-in, or lured into approving a connect someone else started) left their store's keys active inside another company's books, reachable by manual sync within seconds. Now the callback only stages the verified keys on the pending row, the return leg records browser_confirmed_at after the initiator check, and one conditional update flips the row to active exactly once when both signals are present, in either arrival order. A DB CHECK (20260907100000) makes an active row without both signals impossible; every consumer selects status = 'active', so staged keys can never sync. Also: 15-minute handshake TTL on both legs, duplicate callback refused, stale pending rows swept (keys wiped) at the start of the nightly orders cron, manual key entry records the confirmation itself, every path that closes a pending row wipes staged keys, expired/conflict toasts in sv + en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): drop the callback-leg TTL and state the gate's real scope Skeptic pass on the activation gate: - WooCommerce answers any non-200 callback response by deleting the key it just minted and showing a store-side error page, never redirecting back. A 410 for a slow approval therefore stranded the merchant. The callback now stages regardless of age; the session-bound return leg and the nightly sweep enforce expiry, and a stale pending row cannot sync either way. - The second activation signal comes from the initiating user, so the gate does not stop a store admin from approving a link someone else generated (wc-auth delivers keys server-to-server and identifies no approver). The migration header, route comments, decision log and PR body now say so instead of claiming otherwise. Proof of store control is a follow-up. - Every path that parks a pending row also wipes the store metadata the probe staged, so a refused handshake leaves nothing of the store behind. - A duplicate callback POST is a 200 no-op instead of a 409, so the status code no longer tells the state holder whether the merchant has approved. - The expiry message tells the user to remove the unused key in the store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): carry the handshake TTL in the activation flip, wipe metadata on denial Re-verification of the skeptic fixes found two gaps: - The initiator can open the return URL early, so a row confirmed at minute one still flipped when the keys landed hours later; the callback no longer refuses stale rows, so nothing bounded that. The conditional activation update now also requires created_at within the TTL. The callback still answers 200 (no store-side wp_die); the flip matches zero rows and the sweep parks the row. Covered by a pg-real case with both signals present on a 20-minute-old row. - The store-denied path parked the row without clearing the staged store metadata. It now wipes the same five columns as every other parking path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * chore(migrations): move the WooCommerce activation gate to 20260907143000 after main took 20260907100000 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make browser_confirmed_at server-only, finish replayed callbacks, budget the cron sweep Review round on PR #2375: - Superagent P1: browser_confirmed_at was member-writable through the row-scoped RLS policy, so any writer of the company could supply the initiator's signal on a colleague's pending row. New migration 20260907150000 adds a trigger keyed on the JWT role claim (same pattern as enforce_company_writer_role): end-user sessions cannot insert or update the column, service role and migrations pass. Manual key entry now inserts on the service client with company_id/user_id from the verified context. pg-real covers refusal on insert and update plus the server path. - CodeRabbit: a replayed callback for an already-keyed row now runs the activation flip instead of returning early, so a callback cut off between staging and activating is completed by its retry. - CodeRabbit: the cron deadline is fixed before the stale-handshake sweep, so the sweep counts against the route's maxDuration budget. - CodeRabbit: the activation CHECK is added NOT VALID (rows were already conformed by the backfill) and validated in 20260907150000 under the weaker lock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz * fix(woocommerce): make activation itself server-only, install the trigger before validating the gate Second review round on PR #2375: - CodeRabbit: an authenticated company member could still UPDATE ... SET status = 'active' on a fully staged row through PostgREST; the CHECK only proves both signals exist, and the 15-minute TTL lives in the server's conditional activation update. The server-only trigger now also refuses any end-user transition into 'active' (insert or update). Leaving 'active' (disconnect, supersede, revoked-key marking) stays member-writable. pg-real covers an expired, fully staged row: 42501 from a user session, then a member disconnect after the server activates. - Superagent P2: the VALIDATE CONSTRAINT now runs after the trigger is installed, so the gate is never enforced while its signal is still member-writable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LCnbsjSYtD5uwo7ZqJAMQz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a769bc9d03 |
feat(migration): Björn Lundén activation through Lundify's redirect flow (#2374)
* feat(migration): Björn Lundén activation through Lundify's redirect flow BL issued our integration activation key on 2026-09-07. With BJORN_LUNDEN_ACTIVATION_KEY set, the connect step offers "Aktivera i Lundify": the customer logs in at Lundify, picks the company and accepts the scopes, and Lundify returns the company's User-Key to our callback as publicKey with our one-time state echoed as extra. The manual User-Key field stays as a folded fallback for companies that activated inside Lundify already. The callback folds publicKey/extra into the OAuth-shaped locals, so the atomic state consumption, initiator binding and white-label handoff run unchanged; only the final step differs: submitProviderToken (the same client-credentials probe as the manual field) instead of an OAuth code exchange, owned by the consent's company read from the server-written row. consumeOAuthState/consumeHandoff now return that company id. Closes #2323. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L * fix(migration): reset the previous connect attempt before a new provider request Review follow-up: a failed /connect used to leave the earlier consent id and one-time activation URL in place, so the step kept offering a link that completed the previous consent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGDm5S2XPm6np1sKWB4U6L --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8aa5d54510 |
chore(domains): add solbo.accounted.se to the production host inventory (#2378)
Solbo was provisioned on prod on 2026-09-01 (team ce8993e5, brand 6e3c78af) but never added to CUSTOMER_PRODUCTION_WHITE_LABEL_HOSTS. The namespace rule already protects the host; the set is the checked-in inventory the tests pin host by host, and it was stale. Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d29a5bda14 |
fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS Password reset, invite, email change and signup links now resolve the request host against brands.domain server-side. The env var was a second copy of that registry compiled into the browser; every new brand needed the row, the env var, the GoTrue allowlist and a redeploy, and two partners shipped with the env var stale, so their reset mails went out canonical-branded to the canonical host. - New POST /api/auth/password-reset: the login page no longer calls GoTrue directly, so the browser carries no domain list. - lib/domains/trusted-app-origin.ts is async and registry-backed; it also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so previews keep sending links to themselves. - Signup shares the same resolver instead of following the raw host. - Docs and .env.example describe the single registry; GoTrue keeps the redirect allowlist as backstop (hosted: *.accounted.se wildcard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): await the async origin resolver in the billing routes merged from main PR #2370 added resolveRequestAppOrigin callers in billing/checkout and billing/portal after this branch made the resolver async. Await them and move their tests from the removed env var to the brands mock; update the login source-assert test to the server-routed reset. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx * fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs Skeptic and CI findings on #2376, one pass: - A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR, 503, retryable) instead of falling back to the canonical origin: a canonical link is the wrong-brand mail this PR removes. Password reset and email change answer 503 themselves; withRouteContext routes map the code. - A local canonical (dev) trusts other local hosts and ports on the same scheme, so lane servers on 3001-3003 confirm signups on themselves. - GoTrue matches the full redirect_to including the query and `*` stops at `.` and `/`: docs and decision line now prescribe https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**. - The Turnstile contract test asserts the server-routed reset forwards the captcha token (it still asserted the removed browser call). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
7a30f623ba |
fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)
* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions
A user reported five things about the invoice PDF after the English
translation shipped:
- The yellow draft banner pushed the whole document down, so a draft
previewed differently from the final invoice. It is now a small stamp in
the page's top margin (absolute, fixed), repeated on every page, out of
the flow.
- Table rows, totals, the payment box and the notice boxes could split
across a page break, and a section heading could be left alone at the
bottom of a page. Those blocks now carry wrap={false}; headings and the
table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
a line breaks. The editor field is now an auto-growing textarea; the PDF
and the on-screen views keep the line breaks; the Peppol item name
collapses them (single-line field).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF
Skeptic findings on the first commit:
- A word wider than its column was dropped from the page or overprinted the
quantity column, because the no-hyphenation callback gave react-pdf no
break point inside it. Words up to 16 characters still wrap whole; longer
tokens (URLs, e-mail addresses, references) break after separators and
every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
past the page edge. Line descriptions and notes are now kept together only
while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
the SIE export (one record per line). The accrual builder and the Peppol
item name share toSingleLine(); the SIE writer collapses line breaks in
quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.
Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row
Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.
The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): keep words whole by estimated ink width, not a character cap
Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): estimate word width from real Helvetica metrics
Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* docs(invoices): state the bundled-font width margin accurately
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together
- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
is the escape character. It now writes a literal backslash as two, and the
parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
at the description column, so it could split unnecessarily. It uses the
full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
its per-line breakdown carries the (possibly multi-line) descriptions. It
uses the same keep-together estimate as rows and notes, and its line texts
wrap whole words.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
c634cf9ae0 |
fix(banking): return Enable Banking consent callbacks to the initiating brand host (#2371)
* fix(banking): return Enable Banking consent callbacks to the initiating brand host Enable Banking redirects every consent to the one canonical callback URL while browser sessions are per host, so a white-label user reached the callback signed out and was bounced to the unbranded canonical login. The pending row now records the allowlisted origin the flow started from, and the callback uses it for the login bounce, the success redirect and the denial banner. The brand host already holds the session, so its /login forwards straight back into the callback with cookies; the provider redirect URI stays canonical, nothing changes in the Enable Banking console. The shared login redirect helper also stops dragging a callback that arrived on a registered brand host to the canonical login. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz * fix(banking): reload the PostgREST schema cache after adding oauth_origin Skeptic finding: every other ADD COLUMN migration ends with the NOTIFY, and without it PostgREST can reject the new column on connect until its cache refreshes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPom7vRc4jiUKuq3YwjCJz --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9879fb53e9 |
fix(billing): return from Stripe to the brand domain the user started on (#2370)
Checkout success/cancel URLs and the customer-portal return URL were built from NEXT_PUBLIC_APP_URL, so a user on a white-label host came back to the canonical app, where they hold no session, and saw a foreign-branded login. Both routes now resolve the request host through resolveRequestAppOrigin: a registered white-label host stays on its brand, anything else falls back to the canonical app. Return paths stay fixed literals. Claude-Session: https://claude.ai/code/session_01DAGcgQDEAGmhGNSeMbgsn2 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
0016732232 |
feat(invoices): let the invoice list read by row tone without the status column (#2353)
* feat(invoices): let the invoice list read by row tone without the status column A user asked for green/yellow/red row tints in the fakturalista (#2215) so the list can be read without the last column. The need is real; the tint is not the answer the design system already gives: status colours are data, not chrome (convention 12), and DECISIONS 2026-08-03 already ruled out row tints for this list. Settled rows (paid, cancelled, credited) now recede to muted text as a whole row. Open rows keep the foreground and the overdue chip stays the one marker on its row, so three row classes read at a glance with zero new colour: grey is done, dark is waiting for money, dark with an ochre chip is late. Works in dark mode and the brand themes for free and never stacks with the hover or selection tints. The tone lives in a pure lib helper (invoiceRowTone, tested) and one Record in the page maps tone to class; a row tint or a left-edge bar is a three-string swap there if the founder prefers colour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * docs(decisions): record the first-principles choices of the 2026-09-06 evening issue batch Ten lines for #2215 #2351 #2218 #2220 #2312 #2184 #2293 #2239 #2331 #2332 (PRs #2353 to #2362), carried by this PR so the other nine branches do not all touch DECISIONS.md. 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> |
||
|
|
bf7773d74b |
feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2358)
* feat(settings): standard verifikationsserier for new companies, opt-in action for existing (#2184) A new company_settings row now defaults to the standard series set (A manual/bank, B kundfakturor, C inbetalningar, D leverantörsfakturor, E utbetalningar, H periodisering, I bokslut, K lön, L kontantfaktura, M moms) instead of everything on A. The set lives once, as the exhaustive STANDARD_VOUCHER_SERIES_MAP in the resolver; a pg-real test holds the column default equal to it and to the source_type CHECK. Existing rows are not remapped: the per-type settings form gets an "Använd standarduppsättningen" action that fills the set for review and save through the existing PUT, so the switch is a deliberate, audited act rather than a mid-year numbering change nobody decided. Payment rows bound to the other bokföringsmetod are dimmed, not hidden. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 * test(bookkeeping): fresh company_settings row asserts the standard series set, not all-A voucher-series-defaults.pg.test.ts codified the pre-#2184 column default (every source type on A). Migration 20260906210500 replaces that default with the standard set, so the "freshly inserted row" case now asserts the representative letters and full equality with STANDARD_VOUCHER_SERIES_MAP. The explicit-override case keeps proving a company's own layout replaces the default wholesale. No other pg or tool test asserted on the old map. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
4fce2d7b94 |
feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331) - expense_reimbursement line type: kostnadsersättning outside gross, tax, avgifter and the AGI. The engine adds tax-free reimbursements (utlägg, skattefritt traktamente, skattefri milersättning) to the net payout only. - booking debits the claim's liability account (2820) on top of gross, never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K instead of being treated as a nollkörning - salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade, one payslip line per claim); settle_expense_claims_via_salary_run marks the claims paid with an expense_payout_batches row pointing at the salary verifikat, no second verifikat, idempotent on retry; wired into bookLoadedRun and the v1 book route with a pre-check before posting - create_expense_payout_batch refuses claims scheduled on a payslip (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft - "Lägg till utlägg" on the employee row of a draft run; the payslip page labels and removes the lines - pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line - salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited in the unmerged 20260906210300): the database refuses to delete a claim a payslip line still references, whichever path issues the DELETE - deleteExpenseClaim removes the draft line first (before the storno) and keeps refusing with ON_PAYSLIP once the run has left draft - pg-real: delete refused with 23503 on a booked and on a draft run; the app order (line, then claim) succeeds - unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for an expense_reimbursement line (FK011 derives from sre.gross_salary; only benefit_* types are read from line items) Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 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> |
||
|
|
0069a3f59a |
feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran (#2360)
* feat(reconciliation): suggest and book Skatteverkets bundled ROT/RUT payout against several begäran Skatteverket decides per begäran but pays everything it decided that day in one transfer, so the bank row often equals no single open begäran and the 1:1 matcher from #2271 stayed silent; the settle service then refused the amount and the dialog told the user to split the transaction by hand. The candidate is now the exact covering set of 1..4 open begäran whose expected payouts sum to the row (lib/invoices/rot-rut-payout-set-matching.ts, over the existing findExactCoveringSet, ambiguity-refusing). It is computed at read time from the open pool (inbox page, worklist, ingest), no hint column. Confirming books ONE voucher (debit 19xx, one 1513 credit per begäran) through the same writer, marks every begäran paid and links the row once; a bundle is always booked at exactly the decided sums. - match-rot-rut-payout accepts request_ids (1..10) beside request_id - settleRotRutPayoutRequestSet shares the single path's tail - createRotRutPayoutSetEntry; createRotRutPayoutEntry delegates (N=1 unchanged) - inbox pill, RotRutPayoutMatchDialog, Att göra and ingest handle the set - new error code ROT_RUT_SETTLE_SET_AMOUNT; sv/en strings for the set Closes #2239 Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(invoices): PR #2360 review: partially decided begäran stays partially_paid in a bundle The bundle path booked every leg as fully paid and mirrored requested_amount onto every begäran's items, while the single path completes a begäran only when the leg covers requested_total and otherwise leaves it partially_paid for manual handling. A begäran Skatteverket decided at less than requested is a legitimate bundle member (its leg is the beslut, the exact-sum rule is unchanged), so the set path now computes fullyPaid per leg exactly like the single path, passes it to the shared attachSettlementVoucher, and mirrors only the fully paid legs; sibling hints are still cleared for every settled begäran, which carries a voucher and is no longer matchable either way. Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1 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> |