57d6651cfc05eed1dfdedc4b2aa3e1fabea40fed
175 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
08440fed94 |
feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat A first-class Fortnox/SIE migrator path: after SIE import plus bank connect or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or suggestion-matched (0.75-0.89, persisted for review) against the imported verifikat, with a guided review surface, instead of landing as anonymous "Att bokfora" rows. Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account pooling); widen payment_match_log action CHECK with linked_to_existing_voucher (silently unlogged since March). Phase 1: potential_journal_entry_id/method/confidence on transactions with CHECK + invalidation triggers; persistSuggestions in runReconciliation; sweep after bank CSV import with SIE overlap (suppressing auto-categorization); sweep summaries stamped on bank_connections and bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with per-pair server-side revalidation (voucher consumption + bank-leg amount and direction). Phase 2: "Granska forslag" review tab on Transactions with chunked bulk confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode, mutually exclusive with dry_run), attn line, pre-migration row marker. Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant of the account-picker #917 nudge, sweep outcome on the onboarding checklist bank step. Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9 and persist the review band instead of auto-committing fuzzy matches. Migrations already applied to staging under the same versions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): resolve PR review findings in one pass Swedish accounting review (both previously-deferred holes closed): - runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus event alone lands in the 30-day event_log and is not an audit record. - The three match-route storno-conflict branches detach reconciliation links via unlinkReconciliation instead of storno-reversing the linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). - Historical gap quantified on prod (read-only, recorded in DECISIONS): 762 unlogged manual links across 52 companies since 2026-03-23. CodeRabbit: - confirm-suggestions route: maxDuration 300 for full 500-item batches. - AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the async gap-fill probe cannot override an explicit choice. - enable-banking post-backfill sweep: persistSuggestions so the review band is not dropped. - bank-file execute: sie_sweep stamp errors are logged, not swallowed. - ImportResultStep: sandbox keeps the CSV CTA (file import works there). - payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan under ACCESS EXCLUSIVE. - logMatchEvent calls awaited (serverless can freeze unawaited work). - DECISIONS.md stale version reference annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): defer reconciliation-link detach until the match commits Round-2 review findings: - CodeRabbit: the eager unlinkReconciliation call could orphan a transaction if the match flow failed after it. All three match routes now persist NOTHING up front: the final transaction update overwrites journal_entry_id and clears reconciliation_method in the same write, so any failure in between leaves the existing link intact. The release is logged as 'unmatched' after the commit. - Swedish review: the auto_suggested logMatchEvent in runReconciliation is now awaited like every other audit write. - DECISIONS entry split into compliance/CodeRabbit lines and updated to describe the deferred detach. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner The conditional spreads introduced with the deferred detach pushed the scanner's unresolvable-expression count past its ceiling (380 > 378). reconciliation_method: null is correct unconditionally on a confirmed invoice/supplier match (null is already the value on every row that was not reconciliation-linked), so the payloads become plain literals the guard can verify. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
07e89d9b52 |
feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation * fix(invoices): harden Peppol compliance guards * fix(api): narrow Peppol document loading * test(pg): hash Peppol fixture payload * fix(invoices): address Peppol review findings * test(pg): isolate Peppol provider events * test(pg): isolate Peppol submission fixtures |
||
|
|
05380ddf54 |
feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)
* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos Correcting or reversing an entry that already sits 3+ links deep in a rattelse chain (correction_of_id/reverses_id walked in the DB, never description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the caller to book ONE correction expressing the chain's net effect. Agents looped storno+rattelse 10 deep on a live company (63/193 vouchers noise). The guard is advisory, never a dead end: allow_deep_chain bypasses it on every surface (correctEntry/reverseEntry option, REST body, MCP tool arg staged through pending_operations, and confirm dialogs with Ratta anda / Aterfor anda in the web UI). MCP staging pre-flight fires the guard at stage time so the agent reconsiders in the same turn, and the executor re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for the two bypass properties (trimmed to one sentence first). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): retry the Bedrock stream once on transient failures A transient stream death (429/5xx, transport cut, or the two known stream-corruption signatures: 'Unexpected event order' and 'request ended without sending any chunks') killed the whole chat turn, stranding the user mid-answer. The turn now retries once per turn after a short backoff: safe because nothing is persisted until finalMessage() succeeds. A new stream_restart event carries the pre-attempt text snapshot so the chat client resets the partial bubble, drops uncompleted tool chips, and shows 'Forsoker igen...' until the retried stream produces text. Non-transient errors (403, 400) keep the existing immediate-error path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1 apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain, making references/journal-entries.md stale. Regenerated (hand-applied: the generator output is deterministic from the registry). While wiring: the v1 correct route validated allow_deep_chain but dropped it, and the v1 reverse route's strict body schema would have rejected it outright, leaving API clients no bypass when the chain-depth guard fires. Both now forward the flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: re-trigger CI after Vercel infra hang The preview for e527e4044 compiled in 91s then hung 40 minutes in the TypeScript phase and was killed with no error output; a CLI redeploy of the identical code went Ready in 5m. Empty commit to refresh the git- triggered deployment status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): address CodeRabbit review on the chain-depth guard - correction-chain: report rootVoucher only when the walk reached a genuine parentless root; a broken link, cycle, or hop-cap now yields null instead of presenting an intermediate voucher as the chain root. - recordate: propagate allow_deep_chain end-to-end (recordateEntry option, route schema, and a Flytta anda bypass confirm in the dialog); a date move is another storno+rattelse layer and carried the guard with no override path. - v1 correct/reverse: run the chain-depth guard before the dry-run return so a dry run gives the same verdict as the real execution. - dashboard reverse route: 400 on malformed JSON or a non-boolean allow_deep_chain instead of silently reversing without the override; empty body stays the supported no-body case. Tests added. - AgentChat stream_restart: discard the dead attempt's reasoning and re-arm the post-tool paragraph break so a retried turn doesn't render thinking twice or glue its continuation onto restored text. - v1 reverse route doc comment updated for allow_deep_chain. Not changed: the journal-list reverse flow (flagged as a dead end) can never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only for entries that are neither storno nor correction, and such entries have no backward chain links, so their depth is always 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bookkeeping): recordate route test expects the new options arg recordateEntry now takes { allowDeepChain } as a sixth argument; the route test's called-with assertion predates it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d02fd82191 |
feat(vat): add per-account declaration treatments (#1588)
Closes #1457 |
||
|
|
0d3ba5268d |
fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3 The booking-time duplicate guard missed the most common bank-fee twin shapes: - G1: the sibling scan matched on the EXACT date only, so a duplicate import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was invisible. The scan now uses a +-3 day window with a deterministic ranking where exact-date candidates always outrank drifted ones (force=true re-detection stays bound to the reviewed candidate). - G2: booked-ness required transactions.journal_entry_id, so bulk-booked (transaction_voucher_links) and multi-allocated (invoice_payments / supplier_invoice_payments) siblings read as unbooked. The scan now batch-fetches the anchor rows and resolves the verifikat via getPrimaryJournalEntryId (is_transaction_booked semantics). - G3: the ledger scan excluded every voucher linked to any transaction, so a voucher booked from a date-drifted duplicate row escaped BOTH halves and the booking proceeded with no warning. A voucher whose linking transaction itself matches the target (same ore in the same currency, compatible cash account, date in the window) is now returned as the twin with transaction_id set. All candidate picks keep explicit total-order tiebreakers so a force re-detect returns the same candidate the user reviewed, and the SEK-or-null amount contract is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog The duplicate dialog hid its match action for sibling-transaction candidates (canMatch required transaction_id === null), so the user who most needed steering saw only 'Bokfor anda'. manualLink explicitly allows N:1 links, so the match action is now offered for both candidate kinds. Sibling candidates get question-form body copy ('vill du matcha mot verifikatet i stallet?') and an additional 'Ignorera transaktionen' action via the existing POST /api/transactions/[id]/ignore, which is the correct resolution when the row itself is a duplicate import (matching would double-count the bank side, booking the ledger side). Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a destructive toast with no way forward: - the counterparty-template branch of handleQuickReviewConfirm now sets the shared duplicateWarning state exactly like runCategorize, with the force retry bound to the reviewed candidate's voucher - BankReconciliationView's quick-book now opens the same dialog, with match/ignore refreshing the reconciliation lists New sv/en strings: dialog_duplicate_body_sibling, dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level parity tests pin the 409 routing and the dialog affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): duplicate guard on the bulk-book samlingsverifikation path /api/transactions/bulk-book never called detectBookingDuplicate, so a batch containing an already-booked twin minted a second verifikat with no warning. The route now runs the shared per-tx guard before the RPC, with intra-batch exclusions (the other selected txs are distinct events the user picked, and the link-existing target voucher is the batch's own destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the candidate and the flagged tx id. BulkBookDialog routes the 409 into DuplicateBookingDialog for review (view voucher / cancel / book anyway) instead of a dead-end toast; 'Bokfor anda' re-runs the batch with force=true. On force the route re-detects and records each dismissed candidate as BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2 kap 8), parity with the /categorize bypass. Detection failures stay fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions) bypasses this route and remains unguarded; guarding inside the RPC needs a migration and is out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): gate the duplicate-dialog ignore hint on the action being present The sibling body copy mentioned ignoring the row, but two render sites (the manual booking form and the bulk dialog) show sibling candidates without the ignore action. The guidance now lives in a separate dialog_duplicate_ignore_hint string rendered only when the Ignorera button itself renders, so copy never points at a button that is not there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1b829883ae |
feat(reconciliation): promote bulk matching and bridge it from the inbox (#1571)
* feat(reconciliation): accept confidence_threshold on the bank run route Mirror the v1 route: RunReconciliationSchema gains an optional confidence_threshold (0..1) that passes through to runReconciliation as the server-side floor on the apply path. The UI sends 0.85 with a strong-only apply so a pair the fresh re-run scores lower is skipped instead of committed; omitting it keeps the legacy behavior where every selected pair applies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reconciliation): promote the bulk match flow and bridge it from the inbox The dry-run preview with pre-ticked strong matches existed but was never found: users matched whole migrations row by row. Three discoverability changes, no engine changes: - Bankavstamning: an attention line above the toolbar while unmatched transactions exist and no preview has run, with Forhandsgranska promoted to the filled variant. When every ticked preview pair is a strong match (>= 0.85) the apply button relabels to 'Matcha X starka traffar' and the apply sends confidence_threshold 0.85; mixed selections keep the plain label and omit the floor so manually ticked weaker pairs still apply. - Autorun bridge: ?autorun=1 on /reports/bank-reconciliation runs the preview once, only after appliedDates is set and not while datesDirty, so it can never cover a different window than the on-screen lists. - Transactions inbox: with >= 5 unbooked bank rows visible, an attention line links to the reconciliation with autorun (static text + count, no probe; the preview is the honest source of how many actually match). The review step stays: autorun lands on the preview table, one click from apply, and the server intersection guard is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1eebb75269 |
feat(transactions): move an unbooked transaction to another cash account (#1570)
A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.
New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.
UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.
New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e1f13f870a |
feat(import): warn about already-imported rows in the bank-file wizard (#1567)
* fix(transactions): paginate the ingest dedup maps past the 1000-row cap
buildExistingTransactionMaps issued un-paginated selects for the booked and
unbooked dedup maps, so PostgREST silently truncated each at 1000 rows: a
re-import over a wide date range in an active company deduped against a
partial map and inserted everything past the cap as duplicates. Both queries
now go through fetchAllRows with a stable .order('id') for range paging.
Also exports the function and its types for the upcoming read-only duplicate
preview, which must share the exact stored-row universe execute-side ingest
dedups against.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(import): add read-only duplicate preview endpoint for bank files
New POST /api/import/bank-file/check-duplicates (withRouteContext + Zod,
transactions capped at 20000) computes external_ids with the exact
generateExternalId(tx, format, index) derivation execute uses and runs
previewDuplicates: Layer-1 id collisions plus the Layer-2 text bridge with
counting semantics and the currency guard, against the same stored-row maps
ingest builds (buildExistingTransactionMaps). The result is advisory; execute
stays authoritative and mirrors/settlement-account guards are documented
preview/execute differences.
A dedicated endpoint because the generic_csv path re-parses client-side and
never re-hits /parse. Also removes the dead existing_transaction_count field
from the parse response (a raw date-range count consumed by nothing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(import): surface duplicate rows in the bank-file import wizard
Overlapping bank imports used to dedup silently: the wizard promised
'Importera N transaktioner', ingest skipped the twins, and the user saw fewer
rows than parsed with zero explanation. The wizard now calls check-duplicates
after a successful parse AND inside handleColumnMappingConfirm (the
generic_csv path never re-hits parse), and:
- BankFilePreviewStep: warning card in the AlertTriangle pattern ('{count}
rader finns redan', skipped automatically) plus a 'Finns redan' badge on
flagged rows in the 50-row table
- BankFileConfirmStep: repeats the summary card (generic path skips preview)
and the CTA counts 'Importera {parsed - duplicates} transaktioner'
- BankFileResultStep: renders result.duplicates when > 0, closing the loop
ingest.ts documents as unrendered
Execute semantics unchanged: all rows are sent, ingest skips; the preview is
advisory and never promises an exact final number. New strings in both
messages/sv.json and messages/en.json next to the import_psd2 anchors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
45d7f1be4e |
feat(mileage): surface Körjournal in the nav behind a settings toggle (#1540)
* feat(mileage): surface Körjournal in the nav behind a settings toggle The /mileage page shipped hidden: the route works but no nav row points at it. Add company_settings.mileage_enabled (mirroring dimensions_enabled) with a switch in Fönster -> Bokföring, and show the Arbeta nav row when the toggle is on OR the company already has mileage_trips rows, the same hybrid gate as webshop orders, so trips created via API/MCP can never become invisible underlag. UI visibility only, never load-bearing for correctness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): move mileage_enabled migration after already-applied 20260812153208 origin/main merged in 20260812153208 which prod has already applied; a new file sorting before it risks an out-of-order db push abort. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bffa57a565 |
feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)
* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA Customer feedback: MCP-created invoices land in Granskning and then sit as unnumbered drafts that each need individual issuance, and the list filter gives no signal about where the work is. - New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent semantics (no email) and book inline when the company books at issue; sent/overdue unbooked invoices get the deferred /book semantics. Sequential loop keeps voucher numbers ordered; per-item Swedish errors. - Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts and lib/invoices/book-invoice-deferred.ts, now used by the per-id mark-sent and book routes AND the bulk loop, so they cannot drift. Per-id route behavior unchanged (existing route tests untouched, green). - Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices shape), bulkbar with mode-aware action label, ConfirmationDialog with a draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection entirely. - ContextPicker: count annotations on every status view via the one shared predicate (counts always match rows), active view written back to the URL (?status=) for shareable views. No seg/chip row: founder-locked pattern. - Granskning: after a bulk approve that committed create_invoice ops, the summary toast links to /invoices?status=draft to finish with bulk Bokfor. Verified: npm run lint clean, npm test 13845 passed, npm run check:guards passed. New tests: bulk-book route (11), issueAndBookInvoice (7), bookInvoiceDeferred (7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params - Deferred-booking companies (accrual + defer_invoice_booking): a draft in bulk-book no longer gets silently ISSUED (F-number consumed, marked sent, invoice.sent emitted) while reporting status 'booked' with a null journal_entry_id. The draft branch now requires booksInvoicesOnIssue(); otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT code (Swedish + English) before the invoice is touched. - Duplicate ids in one request no longer double-book: the second iteration read the stale pre-loop snapshot, passed the already-booked check, and minted a voucher the CAS claim then cancelled (cancelled verifikat + gap explanation per duplicate). Ids are deduped before the loop. - Bulkbar: the select-all link is hidden when the current view has no selectable rows; "Markera alla (0)" only wiped the existing selection. - Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT payout) rewrite only their own query keys instead of hardcoding '/invoices', so the ?status= view write-back survives them. - /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod and deferred-booking companies where the invoice list offers no draft bulk Bokfor (dead end); the neutral hint sentence stays. Tests: deferred-draft rejection (asserts issueAndBookInvoice never called, sent invoice in the same batch still books) and duplicate-id dedupe (exactly one booking call); both fail without the route fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7cf0e34434 |
feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines (#1534)
* feat(supplier-invoices): sarskild loneskatt (SLP) pair on pension premium lines Booking a tjanstepension invoice (e.g. Avanza) needs the buyer's own SLP beyond the payable: debit 7533 / credit 2514 at 24.26% of the premium (SLF 1991:687). The item-based debit-only form could not express the self-balancing pair, so users had to hand-edit the verifikat. - new leaf module lib/bookkeeping/slp-lines.ts: SLP_RATE (single source, re-exported by the bokslut calculator), isSlpPensionAccount (741x), generateSlpLines (7533 D / 2514 K, nets to zero) - migration adds supplier_invoice_items.apply_slp boolean default false - registration, cash, and privately-paid generators inject the pair for flagged 741x items, mirroring the reverse-charge injection; the balance guarantees keep 2440/1930/2893 at exactly the invoice total; the credit note generator reverses the pair (7533 K / 2514 D) - privately-paid balance guarantee now subtracts existing credits so the SLP 2514 leg never inflates the owner account - schema field apply_slp + guards in all create paths (main route, inbox convert, v1 REST, pending-operations executor): 400 SI_CREATE_SLP_INVALID_ACCOUNT on non-741x accounts, 400 SI_CREATE_SLP_ACCRUAL combined with periodisering - form: advisory hint on unflagged 741x rows with one-click opt-in and a quiet confirmation line when applied; totals box untouched (the invoice total stays the payable); AB review preview injects the same pair via the same generator for parity - year-end double-count guard: calculateSarskildLoneskatt subtracts SLP already posted to 7533 during the year (floored at zero) so bokslut never provisions flagged premiums twice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(api-skill): regenerate suppliers reference for apply_slp The apiskill:check CI gate requires the generated accounted-api skill to stay in sync with the endpoint registry after the apply_slp addition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(slp): carry apply_slp through v1 routes, MCP staging, preview and credit reversal Review findings on the SLP PR: - v1 credit route: SI_FULL_COLUMNS now projects items.apply_slp, so createSupplierCreditNoteEntry sees the flag and reverses the 7533/2514 pair booked at registration (it previously stood forever and the year-end netting under-provisioned). The flag is also copied onto the created credit-note items for parity with the web credit route. - v1 mark-paid: the items sub-select now includes apply_slp, so a kontantmetoden payment via v1 books the cash entry WITH the SLP pair, matching the web mark-paid. - v1 GET ?expand=items: SI_ITEM_COLUMNS includes apply_slp so the flag is readable back through the public API. - credit-note SLP base is abs of the SIGNED sum of flagged line_totals, not per-item abs: a mixed-sign flagged original (+10000/-2000) booked SLP on 8000 at registration and now reverses exactly that, not 12000. The expense-bucket per-item abs convention is untouched. - kontantmetod bank-match preview appends the same generateSlpLines pair the POST books, so the approved lines equal the committed lines. - MCP gnubok_create_supplier_invoice_from_inbox: line_overrides accepts apply_slp (optional boolean), plumbs it into the staged operation's items, and rejects non-741x resolved accounts at staging time with the bilingual SI_CREATE_SLP_INVALID_ACCOUNT texts. - DECISIONS.md: five entries for today's decisions. Every behavioral fix has a test verified to fail without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c35b2547fb |
feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface webshop_orders (order/refund rows, financial-freeze trigger, member select/update RLS, no DELETE) + webshop_store_settings (per-store payment method -> account map), source_type 'webshop_order', multi-store index drop, customer_country, and a one-time woo cursor reset so the switch-over backfills and cross-marks existing feed rows. Tables classified in the full-archive export; pg-real coverage for RLS, freeze and CHECK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): core service (ingest, booking lines) upsertWebshopOrders: two-phase order/refund upsert with FX enrichment, legacy-feed cross-marking, frozen-row protection and field-wise jsonb comparisons (Postgres does not preserve object key order). Booking-line builder: per-rate VAT split with SIGNED buckets (discounts book as revenue reductions), refund mirroring, 3740 residual, per-store account prefill, and advisory export/EU + OSS warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): API routes for list, booking, invoicing and mapping Booking is draft -> atomic claim -> commit (conditional link-back closes the concurrent double-book race; a lost claim cancels the voucher-free draft). Legacy-feed guard honors transactions.is_ignored on both the book and create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an unnumbered draft with dominant-rate fallback and drift-safe unit prices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav /orders lists per-store orders with status tabs (server-side filters), exception chips and one action per row. Booking dialog prefills from the per-store payment-method mapping with an opt-in remember; invoice dialog converts to a draft kundfaktura. The Order nav item renders only for companies with an active WooCommerce connection or existing order rows (Shopify deliberately excluded until its sync writes webshop_orders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(woocommerce): switch the order sync to webshop_orders, multi-store The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund allocations with parent-prorated VAT fallback) and upserts order rows instead of transactions-inbox rows; already-imported feed rows stay bookable and get cross-marked. Multi-store: several active connections per company, per-store panel cards with the account-mapping editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(webshop-orders): decision log entries and ratchet baseline Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption; hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop-orders): resolve PR #1525 review findings and CI failures Review batch (Superagent, CodeRabbit, Swedish compliance review): - Mutual-exclusion claims: booking guards invoice_id, invoice link-back guards journal_entry_id AND treats zero matched rows as the conflict it is (409 + rollback), closing both TOCTOU races. - Freeze v2 migration (20260812124858): the link columns themselves are protected: invoice links immutable, journal links clearable only while the entry is still a draft (the booking rollback path). - Scraped orgnr no longer auto-written to customers.org_number; rate fallback applies only on single-VAT-bucket orders; refunds get their own WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the invoice-mode hint in the booking dialog. - Ingest compares every synced field (billing corrections no longer drop as unchanged); sync guards absent refunds arrays; /sync aggregates per-store results; panel disables all cards while a request runs; orders page separates load failure from empty; account field explains itself. CI: regenerated skills/accounted-api; pg tests restructured for transaction-abort/rollback semantics + freeze-link coverage; unresolvable- expression ceiling 375 -> 378 with documented reason (partial-update payloads in ingest, shapes covered by unit tests). Declined: CodeRabbit docstring-coverage advisory (house style: comments only where the code cannot say it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
11b82cbb91 |
feat(api): installable accounted-api agent skill + openapi-to-skill generator (#1516)
* feat(api): installable accounted-api agent skill + openapi-to-skill generator Three layers, per the July/August 2026 agent-skills ecosystem (skills.sh / npx skills add, as used by Stripe/Cloudflare/Supabase for their APIs): - skills/openapi-to-skill/: generic, installable skill that turns any OpenAPI spec into a consumer-side integration skill, with a portable stdlib-only inventory/condenser tool and an output template + quality checklist encoding the distill-not-restate methodology. - skills/accounted-api/: the installable skill for our own API, rendered deterministically by scripts/api-skill/generate.ts from the v1 endpoint registry + hand-authored overlays (auth, conventions, domain gotchas). CI gate: npm run apiskill:check (core-build.yml). - lib/api/v1/registry.ts: generateOpenApiSpec now emits requestBody (incl. multipart binary parts) and path parameters, and the Zod converter learned .default()/z.record()/.pipe()/.transform(), so the public spec carries request contracts instead of prose-only. Docs: /docs/api landing + /llms.txt now point agents at the skill install; corrected the stale test-key description in the landing (test keys read real data and force dry-run writes; they are not sandbox-company bound). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): escape backslashes in markdown table cells (CodeQL js/incomplete-sanitization) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f0bedc14af |
feat(payments): betalfil UI (betalfil 3/3) (#1505)
* feat(payments): betalfil UI (betalfil 3/3) Bulk-select + Skapa betalfil bulkbar on the supplier-invoices list, preview dialog with per-line editable amount/date and exclusion reasons, payment-files history page with re-download, cancel and a sequential bulk mark-paid (duplicate guard respected, never forced), I betalfil chip on rows in active batches, clearing/kontonummer fields on the supplier form, and the supplier_payment_files namespace in sv+en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sandbox): make the demo AP data betalfil-ready Demo supplier bankgiro numbers were not Luhn-valid, the unpaid demo invoice had remaining_amount 0 (no trigger derives it, so the list said 0 kr kvar att betala), and the company had no IBAN/BIC, all of which excluded the seeded data from the betalfil flow. Numbers swapped for Luhn-valid ones (991-2346 is Bankgirot's test number), a valid OCR added, and both bulk-insert rows set remaining_amount explicitly per the PostgREST normalization rule already documented inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
576a34750a |
feat(payments): payment-batch API surface (betalfil 2/3) (#1504)
Preview/create/list/get/file/cancel routes under /api/supplier-invoices/payment-batches, SI_BATCH_* structured errors, and a fail-closed batch-membership pre-check in the supplier invoice DELETE route (the FK RESTRICT is the backstop). File downloads stamp file_generated_at + download_count but regenerate byte-identically. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c77367ac1a |
feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1490)
* feat(audit): log trusted-role committed_at overrides to behandlingshistorik (#1444) Migrations 20260806150000/160000 let backend writers (service_role, no-claims direct SQL) preserve a preset committed_at on the draft-to-posted transition, but nothing recorded that the transition timestamp was overridden: BFNAR 2013:2 kap 8 wants a log of who did what, when. If a trusted-role connection is ever used against a live company, there is now an audit row separating real from preset commit time. - audit_log accepts action COMMITTED_AT_OVERRIDE (types + Zod filter too) - log_committed_at_override() SECURITY DEFINER writer: audit_log has RLS with no INSERT policy and service_role is not guaranteed BYPASSRLS in every harness; EXECUTE revoked from anon/authenticated so PostgREST cannot expose it as an RPC - set_committed_at() calls the writer in the preserve branch; the stamping branch is unchanged from 20260806160000 - pg tests: override row content (preset value, wall clock, jwt role) for postgres and service_role writers, absence on the stamp paths, and the writer's privilege lockdown Closes #1444 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review(audit): stamp wall_clock with clock_timestamp(), not now() Seeding flows post many entries inside one transaction; now() would pin every override row's wall_clock to the BEGIN instead of the actual transition moment. Captured once so new_state and description agree. Test posts inside an explicit transaction after pg_sleep and asserts wall_clock moved past the transaction start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7411a0171b |
feat(mileage): körjournal with milersättning booking, MCP tools and CSV export (#1448)
* feat(mileage): körjournal with milersättning booking, MCP tools and CSV export New mileage_trips table (RLS, booked-delete trigger per BFL retention), lib/mileage service reusing the payroll schablon rates, /api/mileage routes (trips CRUD, period booking to 7331, salary-run push, körjournal CSV), Körjournal dashboard page + nav, and three staged MCP tools (search-only catalog). Trips book as one verifikat per period via the engine; salary path inserts mileage_taxfree line items. mileage_trips classified in the full-archive export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(mileage): use shared roundOre helper per tightened ratchet baseline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): pending_operations op-type migration + Swedish review findings - New migration pair adds log_mileage_trip/book_mileage_period to the pending_operations operation_type CHECK (pg-real audit). - bookMileagePeriod refuses a period spanning several employees and names the employee in the verifikationstext when scoped (BFL motpart). - vehicle_registration required for förmånsbil trips (schema, service, MCP staging, UI surfaces the field). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): claim-first booking, CSV injection guard and driver column - bookMileagePeriod claims trips (draft to booked CAS) before creating the verifikat, so a concurrent second booking loses the race instead of double-booking; claim reverts if verifikat creation fails. - Körjournal CSV neutralizes formula-injection triggers (OWASP) and adds a Förare column naming the employee per trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): resolve CodeRabbit + Swedish review round: race, drift and hardening - Copying a round trip no longer re-doubles the stored distance. - pushMileageToSalaryRun claims trips before inserting line items (retry can no longer double-pay); CLAIM_LOST replaces misleading NO_TRIPS on lost races. - Booked trips are DB-immutable via a BEFORE UPDATE trigger (new migration 20260807113215): only claim/link/revert transitions and notes edits pass. - Cross-year periods rejected (schablon rates are per calendar year); payroll config year read from the date string, not TZ-dependent getFullYear(). - MCP staged bookings freeze the previewed trip set (trip_ids in params) and the commit fails on drift; validation errors return 400, not 500. - PATCH enforces the förmånsbil regnr rule on the effective row; export validates dates before they reach the Content-Disposition header; employee_id is verified company-scoped on trip creation; stale orphaned claims released. - UI: fetch flags reset in finally; ICU plural for draft summary; distance stored at the column's 1-decimal precision. - Tests: [id] route suite, pushMileageToSalaryRun suite, claim-race, drift, cross-year and update-trigger pg cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): revert-to-draft must clear salary_run_id at the trigger level New migration 20260807114924 replaces the booked-immutability function: a booked -> draft revert now rejects rows keeping salary_run_id, closing the DB-level double-pay path CodeRabbit flagged. pg test pins both directions; the CLAIM_LOST unit test now asserts the revert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mileage): company-scope employee_id on PATCH (Superagent P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mileage): valid v4 uuid in cross-company employee PATCH test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a0ca692fed |
feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d1c411ad6f |
feat(invoice-inbox): surface WhatsApp chat context in booking flows (#1339)
* feat(invoice-inbox): surface WhatsApp chat context in booking flows
The WhatsApp intake bot writes verified human answers (photo caption,
representation deltagare + syfte, sender note, open-question state) to
invoice_inbox_items.channel_context. This makes the in-app booking flows
READ it:
- New core renderer lib/documents/channel-context-notes.ts: deterministic
compact Swedish line ("Representation: Anna Berg (Volvo), Jakob W ·
Syfte: uppföljning av avtal"), capped at 220 chars by dropping whole
participant names ("… och N till"), never mid-name. Representation
first, then user_note; caption only when nothing else exists.
- FieldsRail "Från WhatsApp" block in InvoiceInboxWorkspace: caption,
deltagare, syfte, anteckning rows plus an ochre AttnLine when a chat
question expired unanswered (pending_question.status = moved_to_app).
- Notes threading: book-direct and convert default their notes to the
rendered string server-side when the request carries none (a supplied
value always wins); BookDirectlyDialog prefills its notes input with
the same string so the user can edit it before it lands. Bulk-book
(categorize-core) joins the shared batch note with the per-item
rendered context so the representation trail survives batch booking.
- Inbox list: whatsapp rows get a chat icon and a quiet "Fråga obesvarad"
badge for moved_to_app items. No worklist count change: unresolved
whatsapp items are already counted by countInboxDocuments.
- sv/en strings for every new key; renderer + route + bulk tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(invoice-inbox): honor cleared notes and keep unreviewed captions out of verifikat
Two adversarial-review findings on the WhatsApp surfacing flows, both about
text that lands on an immutable verifikat.
1. A cleared note was silently re-applied. BookDirectlyDialog prefills the
rendered chat context, and the dialog sent `notes.trim() || undefined`
while book-direct and convert defaulted from channel_context on any falsy
value. A user who read the prefill, disagreed and deleted it therefore got
it written back onto a posted entry, removable only through a formal
rättelse. Both code comments claimed "an edited value always wins", which
was false for exactly that edit. Now PRESENCE of the field decides: the
dialog always submits `notes` (empty string included) and the routes only
default when the field is absent from the request (MCP, older clients).
The Zod `.optional()` carrying that distinction is documented at the
schema so it is not "tidied" into a `.default('')` later.
2. The photo caption was auto-burned into verifikat text with no review.
renderChannelContextNotes fell back to the raw caption, and bulk-book
appended the result per item with no per-item notes field at all (the MCP
approval preview deliberately shows no per-item PII either), so unreviewed
chat text reached a WORM record nobody had seen. The renderer now takes
{ includeCaption } and leaves the caption out by DEFAULT: representation
answers and user_note are replies to a question the bot asked, the caption
is not. Only the Bokför direkt prefill opts in, where the user reads the
string in an editable field before booking.
Tests: cleared-notes and whitespace-cleared on book-direct, cleared-notes on
convert, caption-never-defaulted on both routes, caption-not-threaded in
bulk-book, and the renderer's opt-in. The book-direct cleared-notes tests
were mutation-checked (restoring the truthiness fallback fails them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reports): export the chat answers behind a verifikat in the full archive
The verifikat line caps the representation trail at 220 chars and drops whole
participant names ("… och N till"); the complete list (deltagare, syfte,
raw_answer) exists only in invoice_inbox_items.channel_context. That table was
in ARCHIVE_EXCLUDED_TABLES with a rationale predating channel_context ("inbox
workflow state"), so a company leaving Accounted and keeping the full-archive
export as its BFL 7-year record kept an incomplete deltagare documentation for
its representation deductions.
Dumped as a column PROJECTION, not the whole row: the new
MasterDataTableSpec.columns narrows the select to the underlag provenance
(document, matched transaction, created verifikat / leverantörsfaktura) plus
channel_context, so the answers are tied to what was booked from them while
the inbox workflow state (email bodies, OCR output, error messages) stays out
of the archive. The documents themselves remain in dokument/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
86c6af6976 |
feat(api): v1 REST company-settings write endpoint (PATCH) (#1405)
* feat(api): v1 REST company-settings write endpoint (PATCH)
Adds PATCH /api/v1/companies/{companyId}/settings, closing the gap where
the v1 REST surface had no company-settings write (only the staged MCP
tool gnubok_update_company_settings could change them).
- Field set is identical to the MCP tool: payment details (bank account,
bankgiro, plusgiro, swish, iban, bic), invoice contact details (email,
phone, website), contact_person (aliased onto default_our_reference,
exactly as the MCP tool maps it), and invoice_email_texts.
- Validation reuses the shared UpdateCompanySettingsParamsSchema (Luhn
bankgiro/plusgiro, invoice email placeholder whitelist), so REST and
MCP can never drift apart on the Swedish-domain rules.
- Writes directly with an explicit .eq('company_id', ...) filter,
following the v1 customers PATCH precedent: no staged operation, since
REST callers are already gated by the companies:write scope.
- Dry-runnable, mandatory Idempotency-Key, registered in the endpoint
catalogue, scope map, and load-routes; spec snapshot updated.
- The companies:write scope description now mentions the REST endpoint.
No GET endpoint yet (possible follow-up); reads stay on the MCP tool.
Fixes #1348
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(v1): harden company-settings PATCH contract, align risk tier
Adversarial-review follow-up for the settings PATCH endpoint (#1348):
- Declare risk: 'medium' in registerEndpoint, matching the
update_company_settings tier in lib/pending-operations/risk-tiers.ts
(payment settings control where customers send money on future
invoices). The spec snapshot does not pin the risk field, so no
snapshot regeneration is needed.
- Pin the partial-PATCH contract: every column the caller did not
supply must arrive as undefined in the update payload, never null.
A future ?? null on the literal 13-column payload would silently
clear every unsupplied column; the new test fails on exactly that
regression (verified by mutation).
- Cover the body-parsing branches: invalid JSON and non-object JSON
bodies (bare array, string, number, null) each return 400 with the
handler's respective message and never reach the update call.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
9f5a43310b |
fix(salary): recompute entitled_days on existing ledger rows and record pre-cutover taken days (#1403)
The vacation ledger sync carried entitled_days verbatim on existing open rows while re-deriving accrued and taken, so a stale entitled value (for example the flat 25 stored before Semesterlagen 7 § pro-rating existed) survived every sync. The recompute loop now re-derives entitled the same way the lazy-seed path does, with the opening-balance cutover still outranking recomputation for the year containing cutover_date. Opening balances could also not record paid vacation days already taken in the cutover year under the previous payroll system. New additive column employee_opening_balances.vacation_days_taken_this_year (NUMERIC NOT NULL DEFAULT 0, CHECK 0..40) threaded through the shared service, the Zod schema, the MCP staging tool (schema + mergeable fields), the staged-operation executor, the v1 REST routes, and the employee editor form. Ledger semantics for the cutover year, on both seed and recompute paths: entitled = remaining + taken_this_year, taken = booked-run taken + taken_this_year, so remaining keeps meaning remaining and the seeded value survives every subsequent sync. Fixes #1347 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
00ae3540db |
feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical DTOs, customer APIs (web + v1) and invoice send flows so contact person and customer-level invoice CC/BCC addresses survive provider migrations. NULL means unconfigured and empty means an explicit clear, so re-syncs enrich legacy gaps without resurrecting deliberately removed values. Fortnox fixed assets are split into a dedicated follow-up issue. Fixes #1345 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): bump customer metadata migration past pack-slug version Main already contains 20260803230000; keep new versions strictly newest so Supabase branching applies them in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): complete Customer type consumers and make enrichment payload resolvable The preview-pdf mock customer and the makeCustomer fixture now carry the three new metadata fields, fixing the type-check failure in Build (zero extensions) and Vercel. The enrichment update in the migration orchestrator now spells its payload as an object literal typed CustomerMetadataEnrichment (absent keys drop at serialization), so the phantom-column guard resolves the columns instead of counting another unresolvable dynamic payload past its ceiling. The cc/bcc guards also verify element types instead of casting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
16fbcefbbc |
feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths
The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.
Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.
lib/skatteverket/format.ts strip '-' only threw on any input with a space
lib/salary/ku/ku10-generator.ts replace('-', '') first hyphen only, spaces survived
lib/salary/agi/xml-generator.ts strip non-digits stray letters passed the length check
lib/bokslut/ixbrl/validate /^\d{6}-?\d{4}$/ rejected the 12-digit form, no Luhn
A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.
lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.
The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.
KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.
Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(ci): add an upgrade-path job that applies new migrations against real data
The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.
The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.
Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:
rescale money on posted lines empty: would pass seeded: ERROR (immutability trigger)
CHECK violating the ore row empty: exit 0 seeded: exit 3
NOT NULL on a populated column empty: exit 0 seeded: exit 3
Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: record the invariants and upgrade-CI decisions
Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(test): mark the upgrade fixture as CI-only, never a production template
The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.
Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.
Raised by the Swedish compliance review bot on #1364.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
80a14ddfd2 |
feat(mcp): dimension parity for the write/read tool edges (#1274)
Closes the MCP dimension gaps found in the 2026-07-28 audit:
- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
all three layers (tool schema + BulkBookInboxSchema + categorize-core
BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
web inbox bulk-book route and the pending-op executor inherit it via
the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
default_dimensions (names resolve to codes; {} clears on update).
The command layer already persisted the field: only the MCP boundary
blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
the GIN index, covers custom dims the legacy project/cost_center
filters cannot) + include_dimensions to return each line's bag.
The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
default_dimensions (agents could set invoice bags but never read
them back).
- Discoverability: create_voucher, categorize_transaction,
correct_entry, update_invoice descriptions now name dimensions;
categorize_month and invoice_run loadouts include
gnubok_list_dimensions. Trimmed new schema prose to stay under the
tools/list payload budget.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
222e581476 |
feat(categorize): dimension bags end-to-end + runtime template learning (#1273)
* feat(categorize): dimension bags end-to-end + runtime template learning The categorize path could not tag: categorize-core accepted a dimensions bag but no route or UI ever passed one, and runtime template learning dropped the bag entirely (only SIE import produced dimension-carrying patterns). - CategorizeTransactionSchema gains dimensions; the dashboard route, v1 single and v1 batch-categorize apply it to the mapping result's business lines (explicit bag wins over a learned counterparty bag). - categorization_templates.default_dimensions (migration 20260728091000) records the bag of the latest tagged booking; latest-explicit-wins, an untagged booking never erases it. Applied on the legacy single-line template path and the mirrored-refund path; multi-line SIE patterns keep their authoritative per-entry bags. - QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled gate, same as BulkBookDialog), prefilled from the counterparty suggestion's learned bag; hidden for multi-line patterns whose per-line bags would ignore an edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Renumber migration above 20260728120000 (out-of-order vs prod after #1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
951b33363b |
feat(recurring): carry dimension bags on recurring invoice schedules (#1272)
Schedules and their template items now store {sie_dim_no: code} bags
(default_dimensions / dimensions), and the cron generator copies them
onto every spawned invoice + item, so recurring invoices book with the
same projekt/kostnadsstalle tags a manual invoice would. Wired through
the web CRUD routes, the staged-operation executors, and the MCP
create/update/list schedule tools (resolve-don't-select, resolutions
echoed in the preview).
Migration 20260728090000 adds the two jsonb columns (same shape+CHECK
as invoices/invoice_items, PR7 producer parity).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
65c6d4c178 |
Fix/07 27 (#1271)
* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions A PSD2 reconnect silently moved the user's ledger mapping. Account identity came from the provider's account uid, which does not survive a re-authorization at every ASPSP, and a fresh connect to an already-connected bank mints a new bank_connections row regardless. Both paths looked like "an account we have never seen", so the allocator handed out the next free 19xx slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent renewal, roughly quarterly per connection. Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2 promotes that row in place rather than inserting a second one, so it keeps its id and its linked transactions and is re-pointed at the connection that just authorized. The previous holder's connection status is deliberately ignored: one IBAN is one physical account, and the old row often still reads 'active' because the bank killed the session without telling us. The allocator also stopped treating a 19xx number as free just because no cash_accounts row holds it. A chart imported from SIE carries the company's real bank accounts by name with no PSD2 row behind them, which is how a SEK company account got proposed as an unrelated brokerage account. Overflow now skips chart-occupied numbers, falling back only when nothing unnamed is left. Dead connections kept rendering as "Aktiv": status only ever changed when a transaction fetch failed, so a session killed bank-side stayed healthy-looking with a stale last_synced_at while the user read old balances as current. Add probeSessionHealth() and run it in the daily cron over every connection that run did not prove alive, including the ones the loop skips silently (capability gate, all accounts deselected) and the ones parked in pending_selection that the cron never looked at. It acts only on a definite dead answer; anything ambiguous leaves the row alone, since a wrong flip costs a full BankID re-authorization. The all-accounts-deselected branch is reclassified 'synced' to 'skipped' for the same reason: it never contacts the bank, so it must not count as proof of life. The settings row warns when an active connection has not synced in three days or has never synced. Which company a connection belongs to was invisible. Everything was already scoped to ctx.companyId, so there was no cross-tenant leak, but a bank authorized while the wrong company was active looked identical to the right one. Name the company on the connect surface and in the account picker, and say where the connection went when the callback lands under a different active company. Warn (bypassably) before authorizing a bank where the same user already holds live connections in other companies: several ASPSPs allow one active AIS session per login, so the new authorization can kill the others. The history start date already defaulted to the fiscal-year start; the card above it recommended a mid-year date and contradicted the selected option. It now states the fact and offers the shortcut without presenting it as advice. Not addressed: sharing one PSD2 session across companies. company_id is the tenancy anchor on bank_connections and cash_accounts hangs off (company_id, bank_connection_id), so that needs the session to become its own entity. See DECISIONS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): show the posted line description in the voucher preview The "Verifikation som bokförs" preview built its expense debit lines with description set to the raw account number, so the BESKRIVNING column showed "5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura 123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for 2440/2641/26xx, which is why the column read as a mix of friendly labels and bare account numbers, neither of which was the posted text. The preview now renders exactly the line_description the engine writes: the shared invoice-level text on expense lines and 2440, "Ingående moms {rate}% {desc}" on 2641, and the reverse-charge pair taken straight from generateReverseChargeLines instead of being re-derived locally. buildSupplierDescription moves into its own dependency-free module so the client-side preview can call it without pulling the journal engine (and its Supabase server client) into the browser bundle. The account name stays reachable on the AccountNumber hover card. Picked option A from the issue, keeping the fixed invoice-level description rather than propagating each item's own text: the customer-invoice side already writes invoice-level descriptions, so per-item text would create an inconsistency between the two invoice sides rather than remove one, and it would need an aggregation-collision policy in the journal engine. Rationale recorded in DECISIONS.md. Refs #1258 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): restore the copy icon on verifikat rows The row-language rewrite in #1123 reused the copy icon's slot for the new expand toggle, removing the zero-click copy affordance from the bookkeeping list without mentioning it. The leftover orphaned copy_voucher_tooltip key in both message files is what identifies it as collateral rather than a product decision. Restore a copy icon in the row's right-edge action cell, reusing that key for aria-label and title. stopPropagation keeps the click off the row's expand toggle. The icon is hover-revealed on md+ and always visible below it: #1123 collapsed the desktop table and the mobile card into one responsive table, so hover-only would leave touch users with nothing. Copy is no longer gated on posted. The copy_from handler and the GET journal-entries route never looked at status, so copying a draft already worked end-to-end and only the detail-page button hid it; the two list surfaces were already ungated. Both list affordances now respect canWrite, which previously dropped read-only users into a dialog they could not submit. The repo does not render components in tests, which is why #1123 removed this silently. Pin the source shape instead, the same way the copy-invoice query is pinned. Closes #1266 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): revalidate stale invoice match pointers before offering a match potential_invoice_id / potential_supplier_invoice_id are written once, at bank import, and never revisited. When one of several identical recurring invoices was settled by a different transaction, every other transaction kept pointing at the now fully paid invoice. The match dialog then measured the bank amount against a 0 kr remaining balance and reported a "Beloppen skiljer sig ... fakturan blir delbetald" partial payment, and the worklist offered the same dead suggestion as a one-click confirm row. Worse, the manual escape hatch was hidden exactly when it was needed: TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no suggestion exists, so a stale pointer left the user with no way at all to reach the correct invoice. Fixed by revalidating at read time rather than by clearing sibling pointers on settle. Invoices are settled through many paths (both match routes, mark-paid, MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment one is missed, while the candidate lookup covers every route into the list. The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS guards the match routes already enforce. - listSuggestedMatches and the transactions page candidate fetch filter on status + remaining_amount, so a settled candidate yields no suggestion and the manual picker reappears on its own. - InvoiceMatchDialog blocks a settled target with a distinct message and a disabled confirm. Not advisory: both routes reject it outright with MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could succeed. - The supplier detail card now shows remaining_amount like the customer branch, instead of total. On a partially paid invoice it used to print "1 250 kr" directly beside "Differens: 1 250 kr". - match-supplier-invoice clears potential_supplier_invoice_id on the transaction it just matched, mirroring the customer route. No bookkeeping was ever at risk: both routes already refused a settled target before creating a voucher. The damage was confined to a misleading dialog and a dead end. createQueuedMockSupabase gains passive call recording (calls / findCall / findCalls) because the proxy swallowed filter and update arguments, which made the new assertions inexpressible. Refs #1259, #1260 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256) * feat(webhooks): dispatch on emit instead of waiting for the next cron tick The webhook dispatcher ran only on a per-minute cron, so the floor on delivery latency was up to 60 seconds plus the request. An external consumer that wanted to react as a transaction landed had only one alternative: polling /api/events, which the 100 rpm per-key limit makes expensive and which still cannot beat the tick interval. Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is unchanged and remains the retry and sweep path; this only moves the first attempt forward. Wired into the event-bus fanout plus the two routes that enqueue a delivery directly: the :test verb, whose entire purpose is telling someone whether their receiver works, and the manual delivery retry. Three properties are load-bearing and covered by tests. The kick is never awaited, because eventBus.emit is awaited at ~99 call sites including journal_entry.committed and each delivery can burn a 10 s receiver timeout. It coalesces per function instance, so a bulk booking that emits once per row does not schedule one claim round trip per row. It claims 5 rows rather than the cron's 50, because it runs on the tail of a user-facing request. Double delivery is not a risk: claim_due_webhook_deliveries already claims FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so a kick racing the cron sees disjoint rows. Does not close #1201, which asks for a realtime stream for API consumers. This is the cheap half. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(webhooks): stop claiming the kick makes double delivery impossible Adversarial review of the previous commit caught an overstatement in its own comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at the same moment, but claim_due_webhook_deliveries autocommits before any POST is issued, so from then on ownership is only status='in_flight' and a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier cycle's serial loop. Delivery is at-least-once, which is what the public docs already tell receivers ("the same delivery id may arrive more than once ... idempotency is on you"). The comments contradicted that. No behaviour change. The kick does not create this window: the cron claims 50 rows serially against the same 20 s stuck threshold, which is wider than what a batch of 5 can open. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253) * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax calculation, because generateIncomeStatement excludes every source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so planenlig avskrivning posted by the bokslut flow (lib/bokslut/assets/depreciation-engine.ts) was dropped from the income statement and never added back. The bolagsskatt base and the periodiseringsfond 25 % cap were therefore computed on an overstated result: tax too high by roughly 20.6 % of the depreciation. Also exclude the period's final bokslutsverifikation from the fetch. It carries source_type='year_end' as well and reverses every P&L account, 78xx/88xx/7533 included (verified against production closing entries), so once the year is closed it would cancel the add-back this function exists to produce. That hazard already applied to 88xx and 7533; the fix closes it for all three rather than widening it. Scope is deliberately the tax base only. Making the standalone resultatrakning show bokslut entries is a separate, larger change: the same exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed profit for every company that ran the bokslut flow, and it means removing the add-back at four call sites. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): scope the closing-entry lookup to the company and fail loudly Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair. It filtered only on the period id while every sibling query in the same function carries the tenant scope. Primary key or not, service-role paths have no RLS to fall back on and the repo's rule is to filter company_id explicitly, so it now does. It also discarded the query error. That mattered more than it looks: a failed read fell through to closingEntryId = null, which silently re-admits the closing verifikat's 78xx/88xx reversals and understates the tax base, i.e. exactly the failure this lookup was added to prevent. It now throws, and the surrounding catch turns it into the existing 'Failed to read posted dispositions' error. A wrong bolagsskatt is worse than a loud failure. Two regression tests: the lookup carries both eq filters, and a lookup failure propagates instead of degrading to a wrong number. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(storage): drop the client-side DELETE policy on the documents bucket (#1254) * fix(storage): drop the client-side DELETE policy on the documents bucket 20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies". That described the repo, not production. Production carries a users_delete_own_documents policy that exists in no migration file: FOR DELETE TO authenticated USING (bucket_id = 'documents' AND (storage.foldername(name))[2] = auth.uid()::text) Under it, the uploading user can delete the storage bytes of any document they uploaded under the legacy documents/{userId}/... layout, using nothing but their normal browser token. That includes documents linked to a posted verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year retention duty. deleteDocument()'s linked-check and the block_document_deletion() trigger both guard the document_attachments ROW, not the object: the row survives, still pointing at a file that is gone. Reproduced against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes the object; with it dropped the same statement matches zero rows. Company-scoped keys were never exposed (their second path segment is the company id, not auth.uid()), so this only ever reached the legacy layout, which is where most documents still live. Safe because every in-app remove() on this bucket already runs on the service role, covered by service_role_all_documents. Deliberately narrow: users_read_own_documents and users_upload_own_documents stay. The Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT policy now would make existing documents unreadable. That is Phase C. The pg-real test asserts no DELETE and no UPDATE policy over the bucket under ANY name: the hole arrived under a name this repo never used, so pinning a name would not have caught it. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies Review caught two blind spots in the ratchet, both fair. It matched only polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as effectively, and FOR ALL is the shape the one legitimate policy on this table already uses, so a hostile one would look unremarkable in the catalogue. It also read only polqual, so an UPDATE policy carrying its bucket restriction in WITH CHECK was invisible. Both assertions now run through one helper that covers d/w/*, concatenates USING and WITH CHECK, and filters by grantee so service_role_all_documents (how the application does its authorized deletes) is excluded while every client-reachable role is not. A policy granted to PUBLIC has an empty polroles, which is the most permissive case there is, so it is treated as client-reachable rather than as "no roles". Matching on the substring rather than the exact `bucket_id = 'documents'` shape pg_get_expr emits today: a policy written as bucket_id::text or with the comparison reversed would slip past a stricter match, and for a WORM ratchet a false alarm is cheap while a silent hole is not. Adds a probe case that creates a FOR ALL policy and asserts the helper sees it, so the main assertion cannot pass vacuously. That case earned its keep immediately: it caught that node-postgres hands back a raw string for a name[] column, so the role filter needed rolname::text to work at all. Verified against a local replay of the full migration stream: red with the original prod FOR DELETE policy present, red with a FOR ALL probe, green without either. Full pg-real suite 933 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): catch a destructive policy that names no bucket at all Adversarial review of the previous commit found the ratchet still failed open, and reproduced it: a policy with no bucket_id predicate covers EVERY bucket, documents included, so gating on the bucket name discarded exactly the widest hole. The concrete shape is Supabase's own stock "Enable delete for users based on user_id" template, USING (auth.uid() = owner), which is the single most likely form of a future dashboard edit. A destructive policy is now in scope unless it provably cannot reach this bucket, i.e. only a bucket_id predicate naming some other bucket exempts it. The behavioural assertions had the matching blind spot: fixtures were seeded without an owner, so an owner-based policy matched NULL and the DELETE reported 0 rows for the wrong reason. Objects now carry an owner the way storage-api stamps them in production, so those tests fail loudly instead of passing by accident. Two probes pin both directions: a bucketless policy must be reported (and is shown to really permit the delete), and a policy scoped to another bucket must not be, so the ratchet cannot start crying wolf on receipts or sie-files and get switched off. Verified against a local replay of the full migration stream: red with the stock bucketless template installed, green without it. Full pg-real suite 935 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(kontoplan): make a deactivated account reachable again (#1262) is_active=false read as "does not exist" on every read path but as "exists" on the (company_id, account_number) unique constraint, so a deactivated account vanished from the kontoplan with no way back and re-creating it answered "Kontonummer X finns redan i din kontoplan." The write side was already correct: POST /accounts/activate has a toReactivate branch and PUT /accounts/[number] accepts is_active:true. Both were simply unreachable, so this opens routes to them rather than relaxing the read filters, which are load-bearing for AccountsNotInChartError. - Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv" chip and the existing per-row switch reactivates them in one click. - Deactivating an account that has posted lines now warns first, using the usage count already loaded for the Verifikat column. - POST /accounts distinguishes the two collisions and returns the new ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot istallet" rather than a dead-end 409. The stored account is left exactly as it was; values typed into the failed create form are not applied. - bas-lookup consults the company's own chart before the static BAS reference, so a deactivated custom account reads as known and "Aktivera och bokfor" is no longer disabled for it. New in_chart / is_active fields let callers tell "will be added" from "will be revived". - BAS-katalog stops showing "Aktiverat" for an account the company holds but has deactivated; it falls through to a relabelled Aktivera button, and the per-class counts follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255) * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off A foreign supplier charging no Swedish VAT is normally omvand skattskyldighet. With the reverse-charge switch off, createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the momsdeklaration takes a shape Skatteverket rejects. For a fully deductible purchase the net moms att betala is unchanged, which is exactly why this goes unnoticed. The form already auto-ticks reverse charge for eu_business but not for non_eu_business, so that path slips through silently. Adds a pure helper plus a non-blocking banner cloned from the existing rc_account_warning block. Deliberately silent for swedish_business, where 0 % is a genuine exemption that belongs in no ruta at all, and phrased as a question rather than an assertion: a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge, and pushing that user into ticking the switch would manufacture a new wrong verifikat. Does not add the exempt/import/other picker the issue proposes: supplier_invoices.vat_treatment is metadata that no booking or ruta mapping reads, and the codebase cannot book import VAT at all, so an import option would imply ruta 50/60 were handled when they are not. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint Review flagged that the most common foreign document a Swedish small company sees is an invoice carrying the supplier's OWN local VAT, booked at 0 % Swedish VAT with reverse charge correctly off. The banner fires there, and the previous copy only offered "momsfri av annat skal, till exempel en varuimport" as the way out, which does not describe that invoice at all: it is not VAT-free, it carries foreign VAT. Names both legitimate cases explicitly and says 0 % is correct in them, so the hint cannot read as an instruction to tick reverse charge on a purchase where that would produce a wrong verifikat. Title also narrowed to "utan svensk moms" for the same reason. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244) A named persona earns its name once someone has been through onboarding and chosen it: it is their assistant and they named it. Nobody in the sandbox chose anything, so a first name reads as a character the product invented and implies a relationship the visitor never opted into. Both halves move together, which is the point. profile_summary is the agent's own self-description inside the system prompt, so leaving it as "Du är Anna" would have the header say one thing while the assistant introduces itself as another in its first sentence. Nothing else in the stack checks that pairing, so a test now does. Scope: this changes the seed, so new sandbox companies get the new name. The 483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a profile exists, and its caller only runs while verified_at is null). Backfilling those is a production write on demo data and is being raised separately rather than smuggled into a code change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(reports): show the last posted voucher per series in report headers Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and resultatrapport, so a printed or exported report answers which vouchers are actually in it rather than only which dates it spans (#1267). Reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails, delete_last_voucher decrements by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind. Since the point of the line is avstamning, an allocated number would send a reconciler chasing a gap that does not exist, so the label says plainly that the number is the posted one. Scoped to the report own date range, so a Q1 report printed in November says something true about Q1. The balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. Populated in both engines, so the JSON, PDF and XLSX routes all inherit it without signature changes. Best-effort: a header nicety never breaks a report. The pure formatter lives in its own module so the client view does not pull the Supabase query path into the browser bundle. No new i18n keys; both report views and the PDF template are hard-coded Swedish per the "stays Swedish" report surfaces in .claude/rules/i18n.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263) customers.personal_number holds AES-256-GCM ciphertext (20260726110000). Three defects compounded into one broken surface for private customers. The list queried Supabase from the browser with select('*') and rendered the raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads GET /api/customers, which already masks every row, so the ciphertext never leaves the server. Searching by personnummer works again: the client filter had been matching against ciphertext and could never hit. A row whose value cannot be decrypted renders as the placeholder '********-????'. None of the three mask checks recognised it, each having its own '-1234'-only copy, so such a customer could not be edited in ANY field: name and address edits 400'd on a personnummer the user had no way to correct. All three now share one pattern from the new crypto-free lib/customers/mask-personal-number.ts, which the client form can import. Typing a fresh personnummer overwrites the unreadable value, which is the only repair possible: the rejected writes failed whole INSERTs, so there is nothing to backfill. The value was write-only by construction. GET /api/customers/{id}/personal-number is the deliberate drill-in, mirroring the employee convention, gated on the write role because .compliance/ropa.yaml listed no_full_value_read_endpoint as a safeguard for this column; that entry is rewritten rather than left stale, and reveals log actor and customer id but never the value. Also: arcim-migration wrote the identity number as plaintext, which aborts any import containing a Privatperson with 23514 since the constraint flip; and the customer embeds on /api/invoices shipped ciphertext to the browser on every invoice read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance ruta 05 handling for dynamic revenue accounts - Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261. - Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts. - Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05. - Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations. - Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration. * fix: enhance data handling and masking in customer and invoice APIs * fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split 3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a balance on it is filed in the right box already. What was missing is the rate split: unlike 3001/3002/3003 the account number carries no sats, and fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A company posting to the gruppkonto therefore got a ruta 05 total that breakdown.invoices.base25/12/6 did not add up to. Surface those rates separately as staticRateByAccount: rate-only on purpose, because the static map already sums the account and adding it to the dynamic account list would double the filed figure. A test pins that single-count property. Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display list behind report.rutor.ruta05: without it a 3000 balance appeared in the filed projection but not in the report the agent reads back. The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was wrong and is corrected. That check derives its expected base from the output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads the per-rate bases, which are reporting metadata. So the incomplete split never affected a filed return or a warning, only the breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> |
||
|
|
f24b26a139 |
fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215)
* fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
36a1df4f6b |
feat(import): detect and import the article register's Valuta column (#1183)
Fixes #1167. The register export gained a Valuta column in #1166 but the importer ignored it, so re-imported non-SEK articles silently became SEK, breaking the export -> edit -> re-import round-trip. - Column detector recognizes valuta/valutakod/currency (claimed before generic columns; no keyword collision with Momskod). - Parser normalizes to upper-case ISO shape, drops malformed codes with a file-level warning, and carries currency per row. - Execute route validates codes lazily against the currencies table (FK stays the backstop when the reference read fails), imports valid codes, defaults absent to SEK, and in merge mode only overwrites when the file explicitly carries a valid currency. - Edit step shows a muted currency marker next to non-SEK prices; manual column mapping offers Valuta. - Export docblock caveat removed. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d840257c0c |
Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
43f7ccab9e |
feat(invoices): allow BAS class 1-3 posting-account overrides and complete the aktiekapital note (#1121)
- invoice/article posting-account overrides accept active class 1-3 accounts; class 1-2 (balance-sheet) accounts are rejected on VAT-bearing lines so the ruta 05 tax base always books to a 3xxx account - shared posting-account regex across server schemas, pending-operation re-validation, and client forms - share-capital settings (aktiekapital/antal_aktier) feed the annual-report note; kvotvarde derived per ABL 1 kap 6 $; all-or-nothing pair constraint - signed per-rate VAT breakdown on credit-note PDFs; U+2212 to ASCII hyphen Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b0044bfe98 |
fix(arsredovisning): make the aktiekapital note completable via compa… (#1118)
* fix(arsredovisning): make the aktiekapital note completable via company settings The annual report warned every AB that the aktiekapital note was missing and pointed at Installningar -> Foretag, but the referenced columns (aktiekapital, antal_aktier, kvotvarde) never existed and no settings UI was ever built, so the warning was a dead end and no AB could produce a complete note before Bolagsverket filing. - migration 20260723103000: company_settings.aktiekapital (numeric) and antal_aktier (integer) with positive CHECKs; kvotvarde is intentionally not stored since ABL 1 kap 6 defines it as aktiekapital / antal aktier - build-data.ts (K2 and K3 note paths): select only the two stored columns and derive kvotvarde with roundOre - UpdateSettingsSchema: aktiekapital (positive), antal_aktier (positive integer), both nullable to allow clearing - new ShareCapitalForm section on Installningar -> Foretag, rendered for aktiebolag only, with live derived kvotvarde display; wired through the existing CompanySettingsContent save path (empty string clears to null) - sv/en strings; settings route tests (round-trip, clear, 400 on invalid); builder tests for derived kvotvarde and the empty-settings warning Staging (metjnjrhvujscngnpzdv) already has the columns applied and the note verified end-to-end against a rehearsal company. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): address PR review findings on the share-capital note - enforce aktiekapital/antal_aktier as an all-or-nothing pair (DB CHECK, K2/K3 note guard now requires both, partial pair warns instead) - numeric(15,2) column, .int() Zod constraint, maxFractionDigits 0 render - guard numberOrNull against NaN; align kvotvarde preview with schema - strengthen clearing test, add fractional and partial-pair tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
466e55a015 |
Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates |
||
|
|
321e684523 |
Fix/usr fdbck ch (#1105)
* fix(privacy): mask voucher amounts in session replays * fix: persist transaction source filter * fix: clarify invoice filenames and booking previews * fix: truncate long uploaded filenames * feat: add invoice delivery history * fix: harden invoice delivery history * fix: include invoice deliveries in full archive |
||
|
|
3e1ea29d02 |
fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an irreversible voucher or persist a credit note and then run later fallible steps. A failure there previously marked the whole op status=rejected, hiding the posted entity and its id from operators. - new migration 20260722134114: add failed_partial to the pending_operations status CHECK and treat it as terminal in both immutability triggers (immutable, undeletable, never re-claimable) - PartialCommitError + ExecutorResult.partialPostedIds carry the posted ids; the dispatcher writes status=failed_partial with result_data.posted_ids and returns code=partial_commit - instrument only the two named executors; hoist the read-only settlement-account resolution above the storno in the match executor - consumer sweep: status union + query schema widened, failed_partial folds into the Avvisade tab with a badge and posted-ids detail line, bulk/reject routes and MCP tools message it explicitly, worklist and expiry sweep intentionally untouched (not pending work) - tests: pg-real coverage for the new terminal semantics, dispatcher unit tests for both partial paths plus byte-for-byte regression guards Fixes #842 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8294899543 |
test(skatteverket): guard LEGACY_DISCOVERY_HOSTS against config drift (#1093) (#1107)
Add validateLegacyDiscoveryHosts() next to the allowlist in lib/api/v1/base-url.ts and a unit test that pins the registered production configuration (app.accounted.se canonical, app.gnubok.se SKV OAuth pin). The invariant is checked both ways: the NEXT_PUBLIC_SKV_OAUTH_BASE_URL host must be reflectable by discovery (canonical or allowlisted), and every allowlist member must be accounted for by the registered configuration. Drift is now a red CI test instead of a silent production re-auth failure near a filing deadline. No runtime behavior changes. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e11f70b347 |
Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name |
||
|
|
b420f3e1d9 |
feat(domains): dual-domain cutover to app.accounted.se (#1087)
* feat(domains): dual-domain cutover to app.accounted.se The user-facing app moves to app.accounted.se while app.gnubok.se stays alive for machine traffic (MCP connectors, API keys, third-party OAuth callbacks, webhooks, crons), so no third-party callback registration is on the critical path. - next.config: host redirect app.gnubok.se -> NEXT_PUBLIC_APP_URL for page traffic only (/api, /.well-known, /_next excluded). Arms itself only once NEXT_PUBLIC_APP_URL leaves the legacy host, so merging this is inert and the cutover is a pure env flip + redeploy. - skatteverket: redirect_uri pinned via NEXT_PUBLIC_SKV_OAUTH_BASE_URL (Utvecklarportalen registration is slow to change); the OAuth callback now resolves the flow from the state token + stored oauth_user_id via the service client instead of session cookies, which no longer exist on the OAuth host. Legacy same-domain flows fall back to the session. - popup listeners (SkatteverketConnectPanel, AGIPanel) accept postMessage from the pinned OAuth origin; event.source identity check unchanged. - /.well-known discovery docs reflect the allowlisted request host so existing MCP connectors on app.gnubok.se keep a self-consistent issuer/resource after the flip; spoofed hosts fall back to canonical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log dual-domain cutover decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): recency-bound SKV state lookup, exact localhost match in discovery allowlist - The oauth_state lookup now only considers rows updated in the last 10 minutes: bounds how long a leaked/phished authorize URL stays completable, keeps the row set far below PostgREST's 1000-row cap, and surfaces query errors instead of misreporting them as CSRF. - resolveDiscoveryBaseUrl matches localhost/127.0.0.1 exactly; the prefix check reflected spoofed hosts like localhost.evil.example. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d860567976 |
feat(pending): bulk reject selected operations in granskning (#1085)
* feat(pending): bulk reject selected operations in granskning The granskning queue could approve selected operations in bulk but rejection was one row at a time. Adds: - POST /api/pending-operations/bulk-reject: one guarded UPDATE (status='pending' filter) so rows resolved in a parallel session are reported as skipped instead of being flipped; optional rejection_category/rejection_reason applied to every rejected row so agents still learn from bulk 'no'. No high-risk skip server-side: rejecting posts nothing to the ledger. - 'Avvisa valda' button next to 'Godkänn valda'; the existing reject dialog doubles as bulk confirmation (category + note apply to all). - Route tests: 401/403/400/500, not-found, already-handled skip, read-write race, happy path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pending): disable both bulk buttons while either bulk action is in flight Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
87f0d5af48 |
fix: GH issues batch: deadlines opt-ins, SKV reconnect, narrative edit, payment-link gating (#1076)
* fix(errors): close remaining raw-message leaks after #1048 (#337) Follow-up to PR #1048. No user-visible toast or response field can now carry a raw engine or DB message; everything maps through getErrorMessage or the structured-errors registry. - get-error-message: only normalize a code-carrying Error instance into the structured path when the registry knows the code; unknown codes (Node system errors, stray third-party codes, Error-wrapped Postgres SQLSTATEs) fall through to pattern match, Swedish check, Postgres map and the status/context/generic fallbacks instead of returning the raw message. New Swedish-detection pattern for "ar last" phrases and a known-pattern row for "already has a journal entry". - structured-errors: add CANNOT_EDIT_NON_DRAFT (409) and MANDATORY_DIMENSION_MISSING (400) rows, plus common Node network codes (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN, EPIPE) as retryable 503 transients with a Swedish message. - pending-operations commit + bulk-commit routes: map executor error strings through getErrorMessage before responding (raw stays in logs); Swedish passes through, English falls to status-appropriate Swedish. - pending page: toast via getErrorMessage, fixing raw English toasts and "[object Object]" for structured envelopes on commit/bulk/reject. - transactions book + journal-entries routes: untyped catch and DB list errors no longer return err.message; mapped or static Swedish instead. - invoice send + issue-credit-note: partial_failures reasons are now Swedish (raw provider/DB text logged, never returned). - Tests: new unknown-code/Error-instance suite, registry rows asserted, route tests updated off the pinned raw-English expectations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): target the räkenskapsår for yearly VAT redovisningsperiod A yearly filer with a broken fiscal year has a Skatteverket period ending in its FY-end month, not December, and the panel's year state is never maintained in yearly mode (the year picker is replaced by the räkenskapsår selector), so calls targeted the wrong period even for calendar-FY companies filing after year end. The selected fiscal period now rides through the whole chain: panel query strings, draft/validate/ submit bodies, buildMomsuppgift (which resolves the FY bounds so the period id and the figures describe the same räkenskapsår), and the staged-commit path. MCP callers without a fiscal period keep the calendar fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): group same-day skattekonto deadlines into one card Moms, AGI and preliminärskatt legally share the skattekonto date (den 12:e), so a small monthly-moms employer saw 2-3 near-identical rows per month. Two or more pending system rows of the skattekonto family on the same due date now render as one grouped card with the date block once and each obligation as a sub-row keeping its own confirm-to-complete flow. Presentation only: rows, statuses, ICS feed unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): KU + ROT/RUT + long-tail opt-in deadlines, rolling horizon Follow-ups from the #1028 audit left out of the #1057-#1060 fix stack, each with its own condition modeling: - kontrolluppgifter (KU10/KU20/KU31), due 31 Jan (SFL 24 kap. 1 §): opt-in flag suggested from ledger signals (2898 utdelning, 2393/2893 ägarlån; deliberately not 2091, see DECISIONS.md), AB only, mirroring the #1059 EU-sales suggest-and-confirm pattern. - rot_rut_begaran, due 31 Jan after the payment year (Lag 2009:194 8 §): rows generated only for years with actually PAID ROT/RUT invoices, resolved inside the generator; invoice-derived suggestion. - Long tail, explicit opt-in ('Fler deadlines'): OSS quarterly and IOSS monthly with a skipBankingDayAdjustment config flag (EU-law dates stand on weekends), Intrastat (10th banking day of the following month), punktskatt (ordinary skattedeklaration schedule), and fyllnadsinbetalning (12th of 2nd month over 30k / 3rd of 5th month, SFL 62:8 + 65 kap.). Kvarskatt deferred: needs a slutskattebesked date the app does not hold. - Rolling generation horizon: recurring types ~6 months ahead, annual 12 months, mirrored in the backfill expectation keys so the nightly cron never thrashes; regeneration now preserves manual in_progress status; one-time cleanup migration removes existing far-future rows. Migrations also applied to the staging branch, together with the previously missing 20260717xxxxxx deadline migrations (staging had drifted and lacked dismissed_at). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(arsredovisning): keep narrative editable after year-end close The narrative save endpoint refused writes whenever the fiscal period was closed/locked, but Verkstall bokslut closes the period before the arsredovisning text is ever written, so every legitimate save failed with PERIOD_LOCKED and the PDF fell back to placeholder text. The narrative is arsredovisning document text (ARL 6 kap.), not journal rakenskapsinformation, so the bookkeeping period lock does not apply. Saves are now refused only once a Bolagsverket submission for the period is registrerad (ARSREDOVISNING_REGISTERED, 409); the filed artifact was already frozen separately by the submissions immutability trigger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): surface dead SKV connections and nudge reconnect Prod has ~70 companies that connected Skatteverket before the post-connect sync fix (#1010) and silently never synced skattekonto: the only reconnect prompt lived in the settings panel nobody revisits. - transactions-page banner when the connection is needs_reconsent or expired without refresh, linking to /settings/tax - pre-connect note in the connect panel: approve ALL behorigheter on Skatteverket's consent page (previously only shown after a failure) - wire the inert skattekonto.connection.expired event to an email nudge to the token owner; one send per consent episode via claim-first dedup in notification_log (type skv_connection_expired, partial unique index in migration 20260720090000, applied to staging) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(archive): per-year behandlingshistorik covers late-booked vouchers + Drive backup disclaimer The per-fiscal-year archive filtered audit rows by created_at within the period, dropping treatment history for bokslut entries, stornos and SIE imports booked after year end (BFNAR 2013:2 kap 8). The year archive now unions the date window with every audit row touching the period's journal entries and lines, deduped by audit id; line rows (company_id NULL by trigger design) are admitted via a scoped OR and reachable on the service-role backup path. ARCHIVE_FORMAT_VERSION 2->3 forces a one-time Drive re-upload so existing archives pick up the complete history. The Drive card on /import Exportera and the LASMIG texts now state the Drive copy is a convenience backup, not the BFL 7 kap legal archive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(decisions): clarify Arsredovisning narrative save behavior on submission status * feat(invoices): gate payment links behind invoice settings opt-in The payment-link section (manual URL field + Stripe auto-create toggle) was visible on every invoice and auto-created Stripe links on send for any connected company. It is now opt-in per company: - new company_settings.invoice_payment_links_enabled, default false for everyone (no grandfathering of Stripe-connected companies) - invoice editor hides the whole section unless enabled; a draft that already carries a link still shows it so old links stay clearable - enforced server-side in maybeCreatePaymentLinkForInvoice (after the provider lookup, so the extension-free core build never queries), so dashboard, v1, MCP and recurring sends all obey it - new toggle on Settings -> Invoicing, saves instantly; sv/en strings Migration applied to the staging branch; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): add invoice_payment_links_enabled to company settings fixture The makeCompanySettings fixture missed the new required boolean, failing the core-only build's type check of tests/helpers.ts. Default false, matching the migration default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(review): address CodeRabbit, compliance and Swedish review findings Round 2 of PR #1076 review feedback, one change per accepted finding: - pending page: res.json() safe fallback in both commit paths so a non-JSON proxy response cannot surface a raw parser error - bulk-commit: map operation status enums to Swedish display labels in the 'Redan hanterad' skip message - payment-link settings: disable the toggle while a save is in flight to prevent out-of-order PUT responses - deadlines group card: route all UI strings through next-intl (deadlines namespace, sv + en) - archive export: scope the period audit entry lookup to posted/reversed, matching the rest of the export - error tests: assert the exact registry English message for ECONNREFUSED to lock the no-leakage contract - signal routes: log.warn when best-effort lookups swallow a Supabase error (forensics), keep fail-closed behavior - narrative route: document that 'avslutad' submissions deliberately stay editable (never registered at Bolagsverket) - VAT: yearly declarations without an explicit fiscalPeriodId now resolve the räkenskapsår ending in the target year from fiscal_periods instead of assuming a calendar FY (SFL 26 kap 10-11 §§); calendar fallback only when no fiscal period exists - deadlines: IOSS deadline no longer requires vat_registered (Art. 369s has no Swedish VAT registration prerequisite) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9c8e540338 |
fix(invoices): repair send dialog fiscal-period query + editable issu… (#1066)
* fix(invoices): repair send dialog fiscal-period query + editable issuance lines The send/mark-sent dialog queried fiscal_periods with start_date/end_date instead of period_start/period_end; the query always 400ed, and since PR #1023 made that fatal the dialog closed instantly, blocking mark-as-sent and email send for everyone. Also lets accrual companies edit the proposed journal lines before booking (both send and mark-sent), mirroring the mark-paid editor: untouched proposals still book via the server generator; edited lines book verbatim with balance validated at three layers. Credit notes and periodiserade invoices keep the read-only preview. The dialog now also respects defer_invoice_booking (#967). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): harden custom issuance-line validation per review findings Extract the custom-line parse + balance check into a shared validator so the send and mark-sent routes cannot drift. Reject rows carrying both debit and credit, and 29xx interim accounts (custom lines skip accrual schedule creation, so a 29xx balance would never be dissolved). Validate the payload only after the invoice ownership fetch, and emit structured log events when user-edited lines are booked or deliberately ignored, so manual overrides are visible in audit review. Account existence needs no route-level check: the engine already resolves every account against the company chart and throws AccountsNotInChartError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address CodeRabbit findings on issuance line editing Reject malformed JSON bodies with 400 instead of silently booking generated lines; restrict line editing to SEK invoices (custom lines cannot carry FX metadata); round each line before the client balance check to match the server; stop claiming a voucher was created in the mark-sent toast for deferred-booking companies; add programmatic labels to the editor inputs and remove-row buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
05b954ac1d |
feat(deadlines): årsstämma replaces bokslut + moms_yearly auto-complete + EU-sales suggestion (#1059)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal The f_skatt deadline was gated on the F-skatt approval flag (DB default true), giving nearly every company 12 monthly payment reminders for a tax Skatteverket may not have debited at all (64% of all system deadline rows, one lifetime completion). Approval carries no recurring obligation; the monthly duty is payment of debiterad preliminarskatt and exists only while the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.). - Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already collected at onboarding, previously unread) and retitle it as a payment. - Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.). - Declare the prod-only preliminary_tax_monthly column in a migration so installs built purely from migrations stop failing tax-settings saves. - Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses it durably (hard deletes were resurrected by the nightly backfill within 24h); generator, backfill, and every read surface respect it. - Prune upcoming f_skatt rows for companies with no debited amount. Closes part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation The arbetsgivardeklaration deadline was gated on pays_salaries, which is wrong in both directions: a registered employer must file AGI every month including nil months (SFL 26 kap. 3 par.), and companies actively running payroll with the flag off got no AGI reminders at all (each missed monthly filing risks a forseningsavgift). - New company_settings.employer_registered (nullable, no default) gates AGI and the storforetag skatteinbetalning row; pays_salaries remains a fallback for rows saved before the flag existed and keeps its UI meaning. - Migration backfills employer_registered=true from pays_salaries=true and from actual payroll activity (salary_runs). - New employer_seasonal flag: sasongsregistrerade file only for payment months plus a December nil declaration, so only the December-period row is generated. - Settings UI: registration + seasonal checkboxes (sv/en strings). - AGI XML generation no longer auto-completes the deadline as submitted: SFL 26 kap. deems the obligation satisfied only when the declaration has come in to Skatteverket. The Skatteverket extension's kvittens reconcile remains the confirming path; manual filers tick the deadline themselves. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion - Replace the non-statutory 'bokslut' deadline (3 months after FY end, no legal basis, off-by-one month math for broken FYs) with the statutory arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par., the corporate act that gates the arsredovisning filing chain. Migration deletes pending bokslut rows; the backfill cron generates arsstamma rows. - Complete moms_yearly on Skatteverket submission/kvittens: the yearly branch previously returned null with a stale comment claiming annual VAT has no deadline type, leaving yearly filers with an eternally open row. The fiscal-year tax_period label is derived from company settings. - Add /api/settings/eu-trade-signal + a tax-settings callout: companies with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS flags off are prompted to confirm the periodisk sammanstallning obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only, never auto-enables. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): include dismissed_at in DeadlineForm payload The Deadline type gained the required dismissed_at field; the form's submit payload literal must carry it for the Omit<Deadline, ...> shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): make system-deadline dismissal atomic Constrain the dismiss update to source='system' and verify a row was actually updated: a concurrent regeneration can delete the row between lookup and update, and the route must not report a phantom success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
da4d5a39ae |
feat(deadlines): gate AGI on employer registration + stop completing AGI at XML generation (#1062)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal The f_skatt deadline was gated on the F-skatt approval flag (DB default true), giving nearly every company 12 monthly payment reminders for a tax Skatteverket may not have debited at all (64% of all system deadline rows, one lifetime completion). Approval carries no recurring obligation; the monthly duty is payment of debiterad preliminarskatt and exists only while the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.). - Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already collected at onboarding, previously unread) and retitle it as a payment. - Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.). - Declare the prod-only preliminary_tax_monthly column in a migration so installs built purely from migrations stop failing tax-settings saves. - Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses it durably (hard deletes were resurrected by the nightly backfill within 24h); generator, backfill, and every read surface respect it. - Prune upcoming f_skatt rows for companies with no debited amount. Closes part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation The arbetsgivardeklaration deadline was gated on pays_salaries, which is wrong in both directions: a registered employer must file AGI every month including nil months (SFL 26 kap. 3 par.), and companies actively running payroll with the flag off got no AGI reminders at all (each missed monthly filing risks a forseningsavgift). - New company_settings.employer_registered (nullable, no default) gates AGI and the storforetag skatteinbetalning row; pays_salaries remains a fallback for rows saved before the flag existed and keeps its UI meaning. - Migration backfills employer_registered=true from pays_salaries=true and from actual payroll activity (salary_runs). - New employer_seasonal flag: sasongsregistrerade file only for payment months plus a December nil declaration, so only the December-period row is generated. - Settings UI: registration + seasonal checkboxes (sv/en strings). - AGI XML generation no longer auto-completes the deadline as submitted: SFL 26 kap. deems the obligation satisfied only when the declaration has come in to Skatteverket. The Skatteverket extension's kvittens reconcile remains the confirming path; manual filers tick the deadline themselves. Part of #1028. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): include dismissed_at in DeadlineForm payload The Deadline type gained the required dismissed_at field; the form's submit payload literal must carry it for the Omit<Deadline, ...> shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): make system-deadline dismissal atomic Constrain the dismiss update to source='system' and verify a row was actually updated: a concurrent regeneration can delete the row between lookup and update, and the route must not report a phantom success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b8e3fa130 |
fix(vat): enforce decimal vat_rate on supplier invoice items and normalize MCP percent extraction (#1049)
Supplier invoice items store vat_rate as a decimal fraction (0.25) while customer invoices use integer percent (25). The shared Zod schema accepted 0-100, so a percent-shaped vat_rate silently booked 2500 % VAT via line_total * vat_rate, and the MCP inbox-conversion path staged the AI extraction's percent-integer vatRate straight into the decimal column with per-line vat_amount 0. Part of #310. - CreateSupplierInvoiceItemSchema.vat_rate is now a literal union of the statutory decimal set (0, 0.06, 0.12, 0.25) with a unit-hint error, covering the cookie route, the invoice-inbox convert route, and /api/v1 (whose runtime ALLOWED_SV_VAT_RATES guard stays as defense in depth). - New shared normalizeVatRateToDecimal() in lib/vat: percent-shaped values (25, 12, 6) divide by 100, results snap to the legal Swedish set, and anything else (foreign 19/20, non-finite) maps to 0. - gnubok_create_supplier_invoice_from_inbox normalizes vatRate at the extraction boundary and derives per-line vat_amount when the extraction carries none, so the staged header vat_amount is honest. - The pending-operation executor normalizes staged vat_rate on insert, so rows staged before this fix cannot book percent-scaled VAT. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a5e37d3510 |
Fix/build (#1041)
* fix(bookkeeping): harden correction account changes * feat(tax): enhance tax deadline generation with new settings and filing methods - Added new company settings: tax_turnover_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, and periodisk_sammanstallning_filing_method. - Updated deadline generation logic to accommodate new settings affecting VAT and employer declaration deadlines. - Implemented tests for new functionality, ensuring that completed obligations are preserved and not replaced by new pending rows. - Introduced a cron job to backfill missing tax deadlines for companies with settings but no upcoming deadlines. - Updated API routes for generating tax deadlines and handling cron jobs. - Modified database schema to include new columns for tax filing profiles and constraints for filing methods. * fix(invoices): record credit note reconciliation guard * fix(tax): correct automatic deadline settings * fix(tax): key AGI deadline to VAT taxable base and add storforetag payment deadline The 26th filing day for the skattedeklaration (AGI and VAT together) hinges on one statutory measure, a VAT taxable base above SEK 40 million (SFL 26 kap.), not a separate employer turnover. Drop employer_turnover_over_40m and derive the AGI schedule from vat_registered plus vat_taxable_base_over_40m, so a non-VAT-reporting employer is never shown the 26th when its binding date is the 12th. Also: - add a skatteinbetalning deadline row (12th, 17 January) for storforetag, whose deducted tax and employer contributions are due before the 26th filing date - normalize legally incoherent over-40m flag combinations to the earlier small-company schedule in a follow-up migration - replace hardcoded 27 December dates with the banking-day adjustment - extend the 40m help text to cover the SKV-decided early filing election and the payment-still-on-the-12th rule - document the regeneration race repaired by the daily backfill cron Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(migrations): add AGI and VAT filing logic with employer column removal * feat(settings): implement VAT registration logic and update related flags; enhance deadline handling --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1443235cec |
feat(invoices): registrera utan att bokföra + explicit Bokför-steg (#1040)
* feat(invoices): registrera utan att bokföra + explicit Bokför-steg Companies where one person registers supplier invoices / sends customer invoices while ekonomi does the actual bookkeeping had no way to split the two: under faktureringsmetoden every registration/send booked the journal entry inline. - New company setting defer_invoice_booking (default off, accrual only): registering a supplier invoice or sending/marking-sent a customer invoice creates NO journal entry. - New explicit booking routes POST /api/supplier-invoices/[id]/book and POST /api/invoices/[id]/book: create the registration/revenue entry afterwards, CAS-guarded against concurrent booking (a lost race cancels the just-posted voucher with a gap explanation), including periodisering schedules. - Detail pages show "Ej bokförd ännu" + a Bokför button for unbooked accrual invoices; the settings toggle lives under Bokföringsmetod. - mark-paid needs no changes: both payment flows already route on the journal-entry link, so an invoice still unbooked when paid gets the full cash-style entry. - The mark-sent fail-closed rollback now keys on the same gate so deferred sends are not rolled back as booking failures. Fixes #967 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): harden deferred booking after review CodeRabbit round on #1040: - CAS link guards also require a still-bookable status (and uncredited, customer side) so a concurrent mark-paid/credit cannot end up with a double-posting registration/revenue entry. - Settings reads fail closed instead of defaulting to accrual rules. - Detail pages surface the ACCRUAL_SCHEDULE_FAILED warning instead of showing plain success, and the customer page no longer stringifies structured errors into "[object Object]". - The settings form normalizes defer_invoice_booking to false under kontantmetoden so a stale flag cannot re-activate on method switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6bd85f94b6 |
fix(bookkeeping): editable verifikationstext on andringsverifikation (#1035)
The correction header was always built server-side as "Rattelse: <original description>". When the original entry was labelled after the wrong account, the correction kept echoing that stale label even after the user switched to the correct account (follow-up to the line-description fix in #1029). - CorrectJournalEntrySchema gains an optional trimmed description - correctEntry() accepts options.description; blank or absent falls back to the canonical "Rattelse: <original>" auto text - both correct routes (dashboard + v1, which share the schema) thread the description through - CorrectionEntryDialog surfaces an editable verifikationstext field, pre-filled with the auto text; an untouched or cleared prefill is NOT sent, so the server-side fallback stays the source of truth (same only-overwrite-auto-filled principle as #1029) Forward-only: already-posted corrections are immutable per BFL. Fixes #1031 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
edef48471c |
feat: add currency for articles (#834)
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> |
||
|
|
072aedeaf9 |
Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks |
||
|
|
b6332e9ff4 |
Fix/skv connection flow (#1015)
* feat(salary): one-click AGI submission with filing state machine and success feedback The AGI panel required users to know that "Ladda ner AGI-fil" was the generate step, then click submit, signing link, and kvittens manually. A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path that does not exist. - New primary button "Lamna in till Skatteverket" chains the existing endpoints client-side: generate XML if missing, POST underlag, poll kontrollresultat, create signing link, open Mina Sidor in a tab opened synchronously at click (popup-blocker safe). Inline stepper shows each step; the four old buttons become collapsed advanced/recovery actions, auto-expanded in stale-draft and rejected states. XML download stays visible and free for manual filing. - deriveAgiFilingState() + useAgiSubmission() lift the per-period submission record to the run page: the progress rail and salary hero now render the real state machine (generated, underlag inskickat, vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of telling users to "lamna in" an already-submitted declaration. - Success card with kvittensnummer and signature metadata once signed, plus a toast when a poll flips the state while the page is open. - AGI kvittens cron every 15 min instead of every 2 h so filings signed on another device get stamped and emailed promptly. - Advanced submit also auto-generates, and the stale "Lon -> AGI -> Generera" error text now points at the real buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup The bank redirect landed on a blank page for the several seconds the callback spent exchanging the PSD2 session and mirroring accounts, and every failed connect attempt left a status='error' row that rendered forever as an "Atgard kravs" card next to a successful retry, showing duplicate connections to the same bank. - Stream a branded "Slutfor bankanslutningen" progress page from the callback: the shell flushes before the session exchange starts and a script/meta redirect follows when the work completes, with a 30s slow-work escape hatch. Fast outcomes (denial, bad params, unknown state) keep their plain redirects. - Delete never-activated connection rows (no session_id, no accounts_data) on denial or exchange failure, and sweep leftovers for the same bank on the next connect. Established connections keep their "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE SET NULL so deletion has no dependents. - Show "Banken ar ansluten: hamtar dina konton" while the settings panel loads after the callback instead of an anonymous spinner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip A direct POST to /api/invoices/[id]/send against an already-issued invoice re-emailed the customer and posted a second revenue verifikat (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id and orphaning the first entry. Only the UI hid the button; the v1 route and the MCP commit executor already rejected non-drafts. - Non-draft invoices now return 409 INVOICE_ALREADY_SENT. - The draft to sent status flip is an optimistic lock (status guard plus row-count check); journal entry, accrual schedules, PDF archival and the invoice.sent event only run for the request that won the flip. - On a flip failure the journal entry is deferred: the row stays draft and a retry re-runs the pipeline, ending with exactly one verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send - sendInvoiceFromSchedule now auto-creates an online payment link via applyPaymentLinkToInvoice before rendering and passes the payment link QR to the PDF: parity with the dashboard and v1 send routes, which recurring invoices silently lacked. - The recurring cron persists last_run_warning both when a claimed run throws (hourly retries stay visible on the schedule) and when a stale schedule is rolled forward, so a deterministic failure can no longer skip a month silently. - Auto-send is blocked for sandbox companies at the email chokepoint (freeze-and-retain: the invoice is still generated as a draft), covering both the cron and the run-now route with one guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): close the Fortnox payroll API gaps (phases 1-4) Payroll now runs end-to-end through the open API, including onboarding a client from another payroll system, with every write staged for approval. - v1: per-employee payslips (list/detail/PDF), payslip line writes, run roster attach/remove, absence ranges (per-day storage), jamkning fields, cutover opening balances (single + atomic bulk PUT), vacation balance + vacation-year-close. PUT added to the wrapper's idempotency/ test-key set (test keys could otherwise write through PUT). - MCP: 10 new tools (get_employee/get_payslip/list_absence/ get_vacation_balance reads + staged update_payslip_line, register_absence, create_employee, update_employee, set_employee_opening_balances, close_vacation_year), executors, risk tiers, op-type CHECK expansions. create_employee encrypts personnummer at staging: pending_operations never holds plaintext. - Scope-map audit retrofit: 11 formerly unmapped tools now scoped; BREAKING for keys that relied on the 4 default-allow writes. - Cutover: employee_opening_balances (derived lock trigger, self-unlocks on run correction), engine YTD/karens/liability integration, Ingaende saldon section in the employee editor. - Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the hourly/daily divisors; legacy 173/21 preserved exactly at defaults so existing pay math is byte-identical. - Vacation ledger + semesterberedning/arsavslut: recomputed per-year day balances (synced on book/correct, non-fatal), year-close with the min-20 floor, 5-year sparade-dagar expiry to forced payout, and a 2920/2940 drift adjustment via the bookkeeping engine; Semester dashboard card with preview-then-confirm dialog. - Fix: Zod 4 defaults leak through .partial(), which made every sparse employee PATCH fail validation and reset defaulted columns. Migrations 20260713100000/101000/110000/121000/122000 (applied to staging with version rows; prod via merge). vacation_ledger renamed from 20260713120000 to avoid colliding with vat_declaration_totals_rpc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC) The dominant cost was infrastructure: Vercel functions ran in iad1 (Washington D.C.) while Supabase (DB + auth) lives in eu-north-1 (Stockholm), so every request paid 4-5 transatlantic round trips of auth + company resolution before doing any real work (measured 530-1900ms for single-query GETs in prod logs). Pin functions to arn1 and cut the redundant work on top: - vercel.json: functions to arn1, same city as the database - getActiveCompanyId: preference + first-membership queries run in parallel; the fallback result doubles as validation in the common single-company case (one round trip instead of two sequential) - withRouteContext: Server-Timing header and authMs/companyMs/handlerMs in the op-completed log, so latency is attributable per phase - dashboard layout: nav badge counts off the critical path; DashboardNav loads them client-side via the new use-worklist-badges SWR hook with debounced realtime revalidation - swr (new dependency, approved): global provider; useCompanySettings shares one cache entry across consumers and renders from cache on back-navigation instead of re-showing skeletons - /pending: realtime refetch debounced; bulk operations previously fired 4 requests per row-change event - VAT declaration: new get_vat_declaration_totals RPC returns per-account totals, settlement-shape detection (#984) and source_type counts in ONE round trip instead of paging every entry+line through PostgREST. Account lists stay TS-side parameters so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts; DDL already applied to staging. - bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat dynamic-imports the markdown parser, @vercel/speed-insights (new dependency, approved) added for real-user timings The /salary fetch-waterfall fix from the same effort already landed inside 2084a756. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): settle öre-rounded payments from the mark-paid flow An invoice with öresavrundning shows a rounded "Att betala" on the PDF; the customer pays that amount (up to 50 öre off the stored öre total) and the invoice-page mark-paid flow rejected it with MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction match flow already absorbed the residual to 3740. - PaymentBookingDialog now proposes the rounded bank leg plus the 3740 residual line (credit when rounded up, debit when rounded down), resolved via getDisplayTotal from the per-invoice override and company_settings.ore_rounding. - settleInvoicePayment and the v1 mark-paid route absorb the sub-krona residual, gated by planInvoicePaymentForLines: absorption applies ONLY when the caller lines carry the exact residual on 3740; otherwise the strict plan applies (sub-krona partials stay partial, no-3740 overshoots keep the 400), so the GL can never diverge from the AR sub-ledger. - planInvoicePayment absorb-band boundary tightened to >= 1 kr: an exactly-1-kr overshoot used to slip past both the guard and the absorb branch and silently over-record paid_amount (pre-existing on the bank-match path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): resolve all 7 PR compliance findings - ASVS V3.3: per-request CSP nonce on the enable-banking finalize page (mirrors the mcp-oauth consent page); inline scripts are nonce-bound - ASVS V16: decouple callback finalize work from the response stream (eager promise + next/server after()) so a client disconnect cannot drop session persistence or the consent_granted audit emit - ISO 27001 A.8.15: failed audit-event emits log through the structured logger with a stable message for log-based alerting - ASVS V2.3: recurring-invoice cron and run-now routes resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag (defence in depth around the email chokepoint, freeze-and-retain kept) - ISO 27001 A.8.11: stagePendingOperation rejects plaintext personnummer-bearing keys in params/preview_data (key-based guard; EF org numbers make value-matching unsafe) - ASVS V4.5: employee PATCH body is truly sparse; cleared number fields are omitted instead of resetting DB values to hardcoded fallbacks - ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by convention, not 403) on the payslip PDF endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: implement vacation-year basis change validation and error handling - Added tests to block vacation-year basis changes when open balances exist. - Implemented error handling for open-balances guard query failures in the settings route. - Enhanced absence route to reject reversed date ranges with a validation error. - Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability. - Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules. - Improved error messaging for vacation year closure adjustments. - Adjusted employee opening balances handling to preserve audit information during upserts. * feat(settings): add validation to block vacation-year basis change with open balances feat(absence): reject reversed date ranges in absence queries fix(absence): update absence handling to use atomic upserts instead of delete+insert fix(employee): improve validation for jamkning dates in employee updates fix(opening-balances): ensure created_by field is preserved during upserts test(absence): enhance tests for absence range and date validations test(calculation): add tests for age-based avgifter rates and edge cases test(semesterberedning): validate vacation year closure adjustments and error handling test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema * fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |