c84f951a5c2f51279277a506754b2bd94de28249
927 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c84f951a5c |
fix(whatsapp-inbox): name both ways to route receipts for multi-company senders (#1424)
Field feedback after the first live receipt: the link confirmation told a multi-company sender only to set a default company in the panel, so the per-receipt path looked unsupported even though it is the one that actually runs when no default is set. Now it names both: set a default, or send one receipt at a time and answer the company question after each. The guidance also moves to its own paragraph. Run together with the AI disclosure it read as one sentence, which is how a real user came away believing the bot had called itself a "mänsklig AI-assistent". New copy test asserts the promise (both options present, disclosure kept in its own paragraph, single-company message unchanged) rather than the exact wording, so a rewrite stays free but a dropped option fails. Mutation-checked against the previous copy. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
77d20287b9 |
fix(bokslut): unblur the pre-submission warning list on the årsredovisning page (#1415)
* fix(bokslut): revive preflight action links and unblur arsredovisning warnings
Two dead pieces of pre-flight UI wiring:
1. PreflightStep's BlockerRow matched blocker strings with English-only
regexes (/draft journal entries/, /voucher gap/, /trial balance/,
/continuity/), but validateYearEndReadiness emits Swedish (the wizard is
a "stays Swedish" surface). None of the contextual action links ("Visa
utkast", "Hantera nummerlucka", "Öppna balansrapport", "Granska ingående
balans") ever rendered. Swedish alternates added, mirroring the MCP
classifier, plus a link for the unbooked-transactions blocker.
2. The "Innan inlämning till Bolagsverket" warning list on the
årsredovisning page was rendered inside the blurred/inert coming-soon
wrapper, so in the default config (direct submission gated) users could
not read their own pre-submission warnings while the draft-PDF button
next to them stayed active. The warnings describe problems in the user's
own report and apply to the paper PDF path too: they now stay sharp;
only the digital-inlämning explainer remains blurred.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* revert: hand PreflightStep blocker links to #1420 (typed codes)
#1420 solves the same dead-link problem with stable blocker codes instead
of regex string matching, and correctly drops the /bookkeeping/voucher-gaps
branch: that path exists only as an API route (app/api/bookkeeping/
voucher-gaps), so this PR's regex fix would have made a link render that
navigates nowhere. Keeping only the unique arsredovisning warning-list
unblur here so the two PRs stop competing for the same file.
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>
|
||
|
|
c0825e9bd2 |
fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight (#1414)
* fix(bokslut): surface unbooked transactions and AR/AP tie-outs in year-end preflight Two gaps in the year-end readiness layer: 1. Unbooked bank transactions were enforced only by lockPeriod, which runs at step 7 of executeYearEndClosing, AFTER the closing entry has posted at step 4. A period with unbooked transactions reported ready: true from gnubok_year_end_readiness and the wizard, then aborted mid-flow, leaving a posted closing entry on an unlocked, unclosed period. The readiness check now runs the same counter as the lock guard (countUnbookedInPeriod, so the number reconciles with the "att bokföra" badge) as a blocking error, failing closed if the check cannot run. The lockPeriod guard stays as defense in depth. The MCP classifier tags the new blocker as kind unbooked_transactions. 2. The Phase-1 avstamningar (kundreskontra vs 1510, leverantörsreskontra vs 2440) existed as reports (lib/reports/ar-reconciliation.ts, supplier-reconciliation.ts) but were wired only to the ledger report routes, never to the bokslut preflight. The readiness aggregator now runs both tie-outs and surfaces mismatches as warning-severity reminders with deep links, mirroring the bank-reconciliation reminder. Warnings only, never blockers: a difference can be legitimate (FX-settled partials). Skipped entirely for kontantmetod companies, where open invoices are deliberately not on 1510/2440 until the year-end conversion exists and the tie-out is permanently unreconciled by construction. Unconvertible-FX rows produce a "could not reconcile" message instead of a phantom difference. YearEndValidation gains an optional unbookedTransactionCount field; the v1 compliance endpoint and MCP readiness tool pick the new blocker up automatically since they share the same engine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): classify the next-period-IB readiness blocker instead of kind other The blocker "Nästa räkenskapsperiod har redan ingående balanser bokförda" was the only validateYearEndReadiness error with no classifier regex, so it always surfaced as kind: 'other'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): log swallowed AR/AP tie-out failures in the readiness aggregator Compliance-review finding: a rejected tie-out produced no reminder and no log entry, making a failed avstämning control indistinguishable from a reconciled one. Still degrades to no reminder (advisory check), but the rejection reason is now traceable, mirroring the unbooked-transaction check's logging. 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> |
||
|
|
17d48ebb84 |
fix(invoices): guard cash-method partial payments across every payment path (#1413)
* fix(invoices): guard cash-method partial payments across every payment path A never-booked kontantmetoden invoice can only be settled by the generated cash entry (createInvoiceCashEntry / createSupplierInvoiceCashEntry), and that entry always books the FULL invoice: it takes no payment amount. Three payment surfaces still let partial payments through to it, corrupting books: - settleInvoicePayment dropped the fully-paid term entirely, so a partial payment (Stripe sync, mark-paid) booked the entire invoice: over-recognized revenue, over-declared output VAT, and a bank debit that did not match the money received. - The dashboard and agent match-transaction paths fell back to an accrual-style clearing entry against an EMPTY 1510: negative receivable, no revenue, no moms (ML 13 kap 8 § puts each installment's moms in its own receipt period). The comment claimed the credit "gets resolved on final payment", but the cash builder never touches 1510 and books the full total, so the final payment double-debited the bank instead. - The supplier routes had no full-settlement term at all, so a partial payment booked the full expense + input VAT. Fix: one shared predicate (cashPartialBlockReason in booking-mode.ts) rejects generated cash entries unless the payment settles the invoice in full from a fully unpaid state, wired into all six POST surfaces, the agent commit paths, and the three preview routes (so dialogs cannot propose a verifikat the POST refuses). New bilingual error codes INVOICE_PAID_CASH_PARTIAL_UNSUPPORTED / SI_CASH_PARTIAL_UNSUPPORTED. Invoices booked at issue are unaffected: their partial payments keep the normal 1510/2440 clearing path. The v1 match-invoice route already had this guard (VALIDATION_ERROR); its behavior is unchanged. Proper per-installment recognition (proportional revenue + moms per receipt) is the follow-up feature that would lift this restriction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(review): drop stale ML 13 kap 8 § cites for kontantmetoden VAT timing Compliance-review finding: the section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap covers input-VAT deduction, not redovisningstidpunkt. The substantive rule (bokslutsmetoden reports moms at payment, per installment, except at year-end) is unchanged and stated without a section cite until the current-law section is verified. Comments and cookbook prose only; no behavior change. Also fixes the two pre-existing occurrences. 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> |
||
|
|
0449b1d0ea |
feat(settings): WhatsApp brand mark on the WhatsApp settings page (#1418)
The page had no visual signal that it configures a third-party channel, so it read like any other Accounted setting. Adds an inline-SVG WhatsApp mark next to the section title and on the button that opens WhatsApp (replacing the generic lucide chat bubble, which was standing in for a logo it is not). Inline SVG rather than a bundled asset: no network request, scales, and survives the CSP. SettingsSectionHeader gains an optional `mark` slot; every other settings tab is untouched and stays mark-less on purpose, so the rail does not turn into a sticker album. The green is the one place brand colour appears in settings, which matches the design rule that colour belongs to actors rather than chrome. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
88760ae6f6 |
fix(whatsapp-inbox): harden against adversarial review findings (#1342)
* fix(whatsapp-inbox): erase the WhatsApp channel on account deletion whatsapp_phone_links relied on the auth.users ON DELETE CASCADE, but Accounted never deletes auth.users: account deletion is anonymize_user_account plus a ~100-year ban that keeps the auth row as a tombstone, so the cascade never fires and nothing revokes the link. After erasure the link stayed active with a decryptable phone_enc, lookupActiveLink kept resolving the number, and every further inbound message was persisted with body_text and the verbatim raw_payload while the bot kept replying: GDPR Art 17 plus continued collection with no lawful basis. The RPC is re-created verbatim from 20260724150000 with one added block that revokes and crypto-shreds the link, resets its conversation, nulls body_text/raw_payload on that link's messages and deletes outstanding link codes, plus a guarded repair pass for tombstones anonymized before this migration. Covered by a pg-real test that fails against the previous definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): pepper the link-code hash and bound code minting hashLinkCode stored a bare sha256 over CODE_ALPHABET^6 = 30^6 values behind a fixed 'AC-' prefix. The module cited the invite-token pattern, but invite tokens are 256-bit random; this space enumerates offline in about a second, so hashing at rest protected nothing. The sibling phone-crypto.ts already states the team's own threat model for a LARGER space ("a plain sha256 would be brute-forceable ... hence the pepper"), so link codes now hash through the same env-mandated pepper. /link/start was also an authenticated unbounded INSERT that left every earlier code valid. Minting now burns the caller's unused codes (the code the panel shows is the only one that works) and is capped per TTL window, with the route answering 429 instead of throwing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): harden the conversation layer against the review findings Pre-merge hardening of the unshipped chat layer. Every change below has a test that fails without it. Lifecycle and races: - conversation writes go through updateConversation(), an optimistic compare-and-set on updated_at (the trigger makes it a revision counter). The ack winner, the answer worker, the pin refresh and the sweep hold different claims, so blind whole-jsonb writes resurrected answered questions, wiped pending_question and dropped queue entries. - terminal markStatus writes are guarded on processing_status='processing' so a losing worker cannot overwrite the winner's 'done' and null its inbox_item_id. - the message -> inbox item path is idempotent: a pre-check plus a 23505 fallback adopt the item a concurrent worker created, instead of throwing after the WORM document is already committed. - PROCESSING_STUCK_MS 90s -> 5 min. The enforced step budget of one media row already exceeds 90s, so the sweep was re-claiming live workers. - sweep 2b re-arms only when the conversation itself has been quiet, not just the rows: pending_ack=false plus unacked rows is also the state of a live finalize, which produced a duplicate combined ack. - pin expiry re-checks against fresh state instead of writing back a stale whole context, which reverted company choices applied mid-pass. - askNextQueuedQuestion claims the pop before sending, so two answer workers cannot ask the same question twice. Company question: - the state is rolled back when the M6 send fails, so the next receipt re-asks instead of parking receipts behind a question nobody received. - applyCompanyChoice claims the open question (company_options) rather than the state: a double tap confirms once, a transient membership-query error is no longer read as "not a member", and a LATE answer still lands. - at the 48h TTL the parked receipts are kept, not discarded: options and staged rows survive so a late digit or tap still files them, and only rows past Meta's ~30-day media window get the terminal marker. - an out-of-range digit or a typed company name now gets the options repeated instead of silence or the "I cannot answer questions" reply. Inline dispositions: - stop/start/byt/company answers run their side effect BEFORE the terminal wamid row, with a SELECT pre-check for dedupe. Writing the row 'done' first made them at-most-once: a crash in between lost the action forever. Copy and answers: - acks state the extracted currency instead of labelling every total 'kr'. - M17 stops promising "about 10 minutes" when the daily quota tripped. - M18 is sent once per message tracked by the outbound row, so a file whose first attempt died still reaches the sender, including from the max-attempts path. - M11 no longer claims the number is disconnected: 'stopp' pauses, and muted senders now persist no chat content at all. - 'byt' is recognized in every state but awaiting_company (m6-confirm teaches the word, and it was being stored as answer data instead). - text sent while a re-send question is open is kept as a note on THAT receipt with the question left open, instead of binding to another receipt's question. - a quoted reply wins over the pending question and is appended when the quoted question is already answered, so corrections stop landing on the wrong receipt. - context answers keep raw_answer + answered_at like representation does. - finalizeBurst checks the send result: on failure it rolls the question back and leaves the rows unacked for the sweep. PII: - the sender's plaintext number is stripped from raw_payload before it is persisted; replies decrypt the link's phone_enc instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(whatsapp-inbox): record the erasure path and the hardening decisions RoPA gains the account-deletion row (immediate, not via the cron: the auth.users cascade never fires because the row is tombstoned) plus the two new security measures, and its "never in the clear" phone claim is now true of the stored payload. DECISIONS.md records the non-obvious calls: revoke-not-delete on erasure, commit-then-roll-back for the company question, keeping expired company choices answerable, the compare-and-set conversation write, effect-before-terminal-row for inline dispositions, honest M11 copy, and the raw_payload redaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): stop the answer re-claim from following a confirm with M16 A worker that died after applying an answer and sending its confirmation leaves the row 'processing'. The sweep re-runs it, resolveAnswerTarget finds the question already answered, and the user got "I did not understand" immediately after the confirmation they had just received. The fallback is now first-attempt only. The catch comment claiming the sweep retries these rows is corrected too: 'error' is terminal for the sweep, and nothing on the answer path throws anyway (interpretChatAnswer degrades, sends never throw, supabase-js returns errors), so the catch is a programming-error net. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): drop the amount floor on the representation question The Swedish compliance review on #1340 caught a real error in the trigger rules: the representation question only fired above 150 kr, but the duty to document deltagare and syfte is what makes the expense deductible at all (BFL 5 kap 6-7 §) and it is not conditioned on any amount. The 300 kr per person figure I had in mind is the VAT-deduction base cap, a different rule. A 120 kr business lunch would have been booked with no participant trail, which is exactly the deduction Skatteverket denies later. Noise stays bounded by the triggers that were already there: the question fires only for receipt-shaped documents from restaurant, cafe or hotel merchants, at most once per receipt, twice per burst and six times per sender per day, and a single "nej" dismisses it. 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> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> |
||
|
|
bf5ca2c615 |
feat(whatsapp-inbox): GDPR retention cron and RoPA entry (#1341)
PR5b, the final code piece of the WhatsApp intake track. A daily cron (04:15) enforces the channel's retention table; the receipt itself stays 7-year WORM under BFL and is never touched. Retention actions (lib/retention.ts, each isolated and idempotent): - whatsapp_messages transcripts past 90 days: body_text + raw_payload cleared in id batches under a wall-clock budget; the row skeleton (wamid, direction, timestamps, status, inbox_item_id) survives for audit. Only rows still carrying content match. - Rows with phone_link_id IS NULL (unknown senders, orphans) past 30 days: deleted. - Link codes expired more than 24h ago: deleted, used or not. - Sender rate counters idle 2+ days: deleted (minute/day window keys are dead weight after that). - Links revoked 90+ days ago: phone_enc crypto-shredded to '' (column is NOT NULL), one-shot via neq guard; phone_hash and phone_masked kept for uniqueness history and audit display. Route mirrors the sweep cron exactly: withCronContext + registry gate (503 EXTENSION_DISABLED when the extension is off). vercel.json gets the schedule and both Docker crontabs are regenerated. Compliance: new whatsapp.receipt_intake activity in .compliance/ropa.yaml covering purpose, Art 6(1)(b)/(c)/(f) bases with the Art 14(5)(b) note for third-party attendee names, Meta Platforms Ireland as processor (Cloud API, EU SCC addendum, Local Storage region DE), the differentiated retention table, and security measures. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
629069e281 |
feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.
Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.
Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.
New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.
Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f633349c8d |
feat(analytics): show form field labels in session replays (#1416)
* feat(analytics): show form field labels in session replays Replays showed the sidebar after #1412 but form pages were still fully masked, so you could not tell WHICH field a user was interacting with. Tag the shared Label primitive (components/ui/label.tsx, used by every form in the app) with data-ph-unmask: field labels are static i18n chrome, and maskAllInputs keeps every typed value hidden. The one Label whose text is user data, the user-defined dimension name in LineDimensionFields, gets data-ph-mask, which wins even on the same element because maskTextFn checks it first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(analytics): re-mask three Labels that render user data Completing the audit the PR review asked for: a multiline sweep over every Label child found three call sites whose label text is user data, missed by the first single-line pass. Danger-zone confirm labels interpolate the user's email (AccountDangerZone) and the company name (CompanyDangerZone), and the periodisering auto-detect row label is counterparty name + invoice number. All three now carry data-ph-mask. Currency-code and row-count interpolations were reviewed and left visible: categorical UI state, not books data. 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>
|
||
|
|
398c734b93 |
feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token compare); POST verifies X-Hub-Signature-256 over the RAW body before any parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid = dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers media processing via the after() idiom. Rejected and rate-limited content always acks 200 and lands as skipped/error rows, never a retryable status. Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256- GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp; unknown senders get one throttled M1 greeting (1/h, 3/day) behind the sender-quota RPC, with no media download and no content persistence. Intake worker: atomic claim on the message row (the durable job record), company resolution (default -> sole membership -> M6 fallback, no item), per-company inbox quota (ack-and-drop, M17 once per 10 min per sender), MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate check, then the shared uploadAndExtract funnel (source 'whatsapp', channel_context caption, whatsapp_message_id) and the M4 ack with extracted merchant/total/date. Failures wrap to 'error' + error_message + one M18. uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId; email/upload paths behaviorally unchanged. Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice (M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM call, sweep cron, retention cron. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bd3592cb99 |
feat(db): WhatsApp channel schema (phone links, messages, inbox source) (#1337)
Foundation for WhatsApp receipt intake (plan 2026-08-01), no runtime callers yet: - whatsapp_phone_links + whatsapp_link_codes: verified phone -> user binding via single-use 10-min codes (invite-token pattern). Peppered HMAC lookup hash + AES-GCM encrypted number; one ACTIVE link per phone and per user (partial unique, revocation preserves history). User-scoped RLS (a binding belongs to a person, not a company). - whatsapp_conversations + whatsapp_messages: deterministic state machine state and the message log, which doubles as the durable job record for persist-first webhook processing. Partial unique index on inbound wamid = the at-least-once dedupe key. Service-role only. - whatsapp_sender_rate_counters + check_and_increment_whatsapp_sender_quota: the pre-binding limiter keyed by phone hash; EXECUTE granted to service_role only. - invoice_inbox_items: source CHECK widened to 'whatsapp', plus whatsapp_message_id (one item per delivering message) and channel_context jsonb, kept separate from extracted_data so verified human answers never share a container with untrusted OCR output. document_attachments.upload_source CHECK gains 'whatsapp'. - whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (full-archive coverage contract). pg-real on a fresh DB: 975/975 incl. the new whatsapp-channel suite (RLS visibility, unique/rebinding semantics, wamid dedupe, CHECK widenings, quota RPC caps + grant lockdown). Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
02e48efe11 |
refactor(invoice-inbox): export uploadAndExtract from lib (#1336)
Pure move of uploadAndExtract and its private helpers (sanitisers, page-count/slice, sandbox check, MIME/size consts) from index.ts into lib/upload-and-extract.ts so a future channel extension can import the shared funnel the way document-extraction already imports extract-invoice-fields. No behavior change; existing tests prove it. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3b3adf96c7 |
feat(invoice-inbox): receipt-aware extraction + OCR pipeline fixes (#1331)
Receipts and invoices were extracted through one invoice-shaped prompt with no document classification. The extractor now also returns documentKind, payment method (+ card last4), purchaseTime, merchantCategory and legibility, validated with .catch(null) so a hallucinated label degrades to unknown instead of sinking the parse. The FieldsRail shows type and payment method above the editable fields. Pipeline fixes, all verified against real failure paths: - PDFs >3 pages: extract from a pdf-lib slice of the first 3 pages instead of skipping entirely (issue #553 gate); truncation recorded in extracted_data.pages and shown in the UI. - Oversized images (>4 MB, over Bedrock's 5 MB cap): downscale to <=2000px JPEG via sharp before base64, instead of erroring to an empty result. - HEIC/HEIF: attempt sharp transcode to JPEG; when libvips lacks HEIF (prebuilt binaries), fall through to today's behavior but show an explicit hint instead of silently blank fields. - Oresavrundning: prompt rule + totals.roundingAmount so receipt totals reconcile with subtotal+VAT for exact-amount transaction matching. - retry-extraction overwrites extracted_data wholesale: a confirm now guards against silently destroying manual field edits. Deliberately NOT added: retry-on-transient-Bedrock-error; the SDK already retries twice by default (maxRetries=2). Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b1f71912a2 |
feat(analytics): show static nav chrome in session replays (#1412)
* feat(analytics): show static nav chrome in session replays Replays previously masked every text node via maskTextSelector '*', which made them unreadable: even sidebar labels and buttons were asterisks. Add a fail-safe maskTextFn: text stays masked unless its nearest tagged ancestor is data-ph-unmask, and data-ph-mask re-masks user data nested inside an unmasked container. Untagged text stays masked, so a forgotten tag can never leak user data. Tags the four nav containers and the skip link; the notification count bubbles inside them are re-masked. User menu, company switcher and page titles stay masked on purpose: on detail pages the title is user data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(analytics): use English domain terms in masking comment CodeRabbit: comment used 'enskild firma' and 'personnummer'; repo guideline is English for all comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6dcd401fb5 |
chore: union-merge DECISIONS.md to stop phantom-green conflicting PRs (#1351)
Every concurrent PR appends to DECISIONS.md, so any two of them conflict at EOF. The real damage is not the conflict: GitHub runs no Actions on a conflicting PR, so `gh pr checks` shows a short all-pass list that reads as green when the test suite never ran. Union merge keeps both sides, which is the correct semantics for an append-only log with no ordering contract beyond the date prefix. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
af9db54405 |
docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys (#1407)
* docs(self-hosting): AI runs on AWS Bedrock, not ANTHROPIC/OPENAI keys A self-hoster followed SELF-HOSTING.md, set ANTHROPIC_API_KEY and OPENAI_API_KEY, and found document interpretation dead (with a 30s extraction-poll hang per upload). Neither key has been read since the ai-chat / receipt-ocr / ai-categorization extensions were removed in PR #157: all AI (document extraction and the assistant) goes through Claude on AWS Bedrock via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. - SELF-HOSTING.md, DOCKER.md, .env.example: document the Bedrock credentials and model overrides; point plain-key support at #1406 - SELF-HOSTING.md: drop the receipts-bucket setup for the removed receipt-ocr extension - EXTENSIONS.md: replace removed extensions with live ones in trees and examples; drop the AI-consent system claims (lib/extensions/ ai-consent.ts no longer exists); services-pattern example now uses the real stripe/skatteverket services - lib/init.ts: startup env warning now checks the AWS keys instead of the two dead vars, so a misconfigured self-host logs the truth Direct Anthropic API key support and pluggable providers: #1406. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: address review, dedupe tree entry and match provider-chain claim to code extractInvoiceFields bails without both static AWS keys, so only the assistant client actually falls back to the credential provider chain. 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> |
||
|
|
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> |
||
|
|
2296c0cd59 |
fix(auth): provision invitees server-side when signups are disabled (#1404)
* fix(auth): provision invitees server-side when signups are disabled Self-hosted installations with GoTrue disable_signup broke the invite flow silently: invitees without an account were routed to /register, where supabase.auth.signUp fails with "Signups not allowed for this instance", surfaced only as a generic toast. New server-only env flag AUTH_SIGNUPS_DISABLED (documented in .env.example) mirrors the GoTrue setting. When true, POST /api/company/members/invite checks check_email_exists and, for invitees without an account, provisions one via auth.admin.inviteUserByEmail with a redirect back to /invite/<token>, before the Resend email and before the invitation row is written so a provisioning failure leaves nothing half-created and the admin can retry. The response now carries user_provisioned alongside email_sent, and a provisioning failure returns 502 with a Swedish message mapped through getErrorMessage instead of a silently-successful invite. /auth/callback now routes type=invite verifications to /reset-password (the existing set-password surface) instead of dropping the passwordless user on the dashboard, and preserves the invite token from next=/invite/<token> as the pre-auth invite cookie so the existing reset-password invite handoff accepts the membership right after the password is saved. getErrorMessage learns two GoTrue patterns: "Signups not allowed" (account creation closed on this installation, contact your inviter or administrator) so the /register dead end is explained even for flows that bypass provisioning, and "Error sending ... email" (GoTrue SMTP not configured) so the 502 above is actionable. Hosted is untouched: the flag is unset there and every new code path is gated on it. Fixes #1335 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): restore check_email_exists RPC and harden self-host invite config Adversarial review of #1404 found that the check_email_exists function the invite flow depends on does not exist anywhere: it shipped in PR #229 and was lost in the #244 migration consolidation before ever reaching prod (verified missing on the hosted production database directly). Today app/api/team/accept destructures only { data } from the RPC call, so alreadyHasAccount is silently null on every deployment and the invite page routes even existing-account invitees toward /register. - New migration 20260804140000 restores the function exactly as originally shipped: SECURITY DEFINER over auth.users, EXECUTE revoked from PUBLIC, anon and authenticated, granted to service_role only (prevents email enumeration). Fixes hosted prod behavior too once applied. - New tests/pg/check-email-exists.pg.test.ts locks in existence, case-insensitive matching, false-for-unknown, and the role grants. - .env.docker.example gains the AUTH_SIGNUPS_DISABLED block self-hosters actually use; both env templates now note that the GoTrue redirect URI allow-list must include /invite/* or the invite email redirect silently falls back to SITE_URL. - Invite route test for the existsError branch: RPC failure logs a warning and provisioning proceeds anyway (GoTrue is authoritative). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): mask invitee email in provisioning-failure log (#1335) 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> |
||
|
|
a9242551eb |
fix(reports): stop double-counting arets resultat on open years (#1401)
* fix(reports): stop double-counting arets resultat on open years The balance sheet computed the synthetic Arets resultat section purely from class 3-8 rows while 2099's posted balance already sat inside the class 2 Eget kapital sections. When a resultatavslut was posted to 2099 on an open period with its counter-line outside class 3-8 (class 0/9 or missing class), the result was counted twice and the report raised a false imbalance whose differens equaled the 2099 balance. The period-result filter now takes every row NOT in class 1-2, written as a negated range so null/undefined account_class rows are included. The invisible counter-line of a mangled resultatavslut then offsets inside the period result and 2099 is never counted twice, while a genuinely untransferred prior-year result still produces a real differens and the existing diagnosis still fires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record balance-sheet residual classification decision (#1333) 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> |
||
|
|
97bbba323f |
fix(salary): report F-skatt compensation on FK131 only, not FK011 too (#1402)
* fix(salary): report F-skatt compensation on FK131 only, not FK011 too An F-skatt payee's cash compensation was passed through as grossSalary unconditionally while also being routed to fSkattPayment, so the same payment was double-reported in the AGI individuppgift as both FK011 (KontantErsattningUlagAG, underlag for arbetsgivaravgifter) and FK131 (KontantErsattningEjUlagSA). Per Skatteverket's AGI spec these fields are mutually exclusive for the same payment stream: F-skatt payments form no underlag for arbetsgivaravgifter. Fix at the data layer: generateAgiDeclaration now zeroes grossSalary for f_skatt payees so FK011 is never emitted for that payment, while fSkattPayment keeps carrying it to FK131. A generator-side guard was deliberately not used because an IU can legitimately carry both fields for genuinely mixed payments. The empty-IU filter already keeps f_skatt rows via fSkattPayment, so no individuppgift is dropped; FK487/avgifter totals were already correct (calculation engine sets avgifter_basis 0 for f_skatt) and are covered by a regression test. Both the dashboard route and the v1 public route call this helper, so both are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): exclude f-skatt rows from avgifter override aggregation (#315) 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> |
||
|
|
f80e933f8d |
docs: restore decision log entries from the merge-wave strip (#1400)
* docs: decision log entries stripped during the 2026-08-03 merge wave During the codex-recovery merge wave, DECISIONS.md diffs were removed from in-flight branches to stop append-conflict thrash between sibling PRs, with the lines collected for central landing. This restores the five entries (issues #316 x2, #1349, #748, #362) that were not already re-added by their PRs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: decision log entries from the final merge-wave branches Lines reported by the #1391 (asset disposal), #1392 (customer metadata) and #1393 (pooled tax depreciation) agents after their merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5ca64bde30 |
feat(bokslut): IL 18 kap pooled tax depreciation with method election (#1393)
* feat(bokslut): IL 18 kap pooled tax depreciation with method election Rakenskapsenlig (huvudregel 30 / kompletteringsregel 20) and restvarde 25 as a company-level annual pool separate from per-asset book depreciation. Method election persisted with immutable snapshots and book-conformity confirmation for rakenskapsenlig. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): move tax depreciation migrations to coordinated versions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): keep tax depreciation view loadable when a saved election goes stale A predecessor's changed closing value can push a saved elected deduction above the new statutory maximum; the view now falls back to the statutory recomputation so the snapshot is flagged stale instead of crashing. Ratchet naive-ore-round baseline down by 3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): resolve tax-depreciation period selects statically The no-phantom-columns guard counts every select it cannot resolve toward a hard ceiling, and the PERIOD_COLUMNS join pushed the repo 4 over (364 > 360). Inline the literal column list at the four call sites so the guard verifies these columns instead of skipping them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bokslut): address review findings on tax depreciation election - DepreciationPanel: gate the saving flag on a dedicated save sequence so a successful save (which refreshes the view and bumps the request version) no longer leaves the card permanently busy - computeTaxDepreciation: refuse kompletteringsregel_20 with a positive basis and no acquisition cohorts instead of degenerating to a full write-off the cohort evidence does not support (IL 18 kap. 17 §) - migration 227000: judge the asset-method guards on NEW.disposed_at so reversing a disposal cannot reactivate a grandfathered non-linear row - migration 227200: require snapshot column completeness in the CHECK; SQL NULL semantics let partially populated snapshots pass the pure arithmetic comparisons - depreciation route: use the string issue code 'custom' like the rest of the codebase instead of the Zod 3 compat enum Co-Authored-By: Claude Fable 5 <noreply@anthropic.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> |
||
|
|
cb3ef45f14 |
feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) (#1391)
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) Disposal books depreciation to the disposal date, clears cost and accumulated depreciation, books gain (3973) or loss (7973), applies output VAT on third-party sales, honors the ML 5 kap. 38 § verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning server-side from tax years and original input VAT. The voucher, the disposal-date depreciation schedule and the immutable register state commit in one dedicated commit_asset_disposal RPC transaction that delegates voucher numbering to commit_journal_entry. Fixes #325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): harden disposal per review and pg-real findings - commit_asset_disposal now uses the NULL-safe caller_is_company_member() guard (tenant-guard ratchet) and passes the allowed 'user_accept' commit_method instead of the unlisted 'asset_disposal' value - disposal metadata invariants validated in the RPC (non-negative proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no proceeds) since the RPC is independently callable - new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so the migration never blocks writes on the hot journal_entries table - disposeAsset paginates fiscal periods and depreciation schedules with fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||) - engine imports shared AssetDisposalType/AssetJamkningDirection/ VatTreatment unions; post-commit reload retries once and logs before surfacing, so a transient read cannot masquerade as a failed disposal - dispose page parses Swedish-formatted amounts (125 000,50) and blocks submission on unparseable proceeds - assets pg tests write disposal attributes in the disposal transition itself and gain a regression test that the register is frozen after Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1c9d378df8 |
feat(auth): enforce session idle and absolute timeouts (#1387)
* feat(auth): enforce session idle and absolute timeouts Hosted browser sessions now carry an HMAC-signed, HttpOnly cookie holding session start, last activity and sign-in method, bound to the Supabase session. Middleware enforces a 30 min idle and 12 h absolute limit (reason-coded redirects to /login), a heartbeat route advances idle activity from real user input, and a client controller warns 2 minutes before expiry. BankID users are routed back to BankID on re-auth via a short-lived method hint. API-key and MCP bearer surfaces are exempt; self-hosted installs default off and can opt in via env vars. Fixes #362 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): derive session-timeout signing key via HKDF The HMAC key is now HKDF-derived with a purpose-bound info string, so the SUPABASE_SERVICE_ROLE_KEY fallback never uses the privileged credential directly as a signing key. Addresses the security review finding on PR #1387. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): back signature bytes with a plain ArrayBuffer crypto.subtle.verify requires a BufferSource; Uint8Array.from is typed over ArrayBufferLike, which the Vercel TypeScript build rejects. Decode base64url into a Uint8Array constructed over a fresh ArrayBuffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): address session-timeout review findings - signSessionTimeoutState returns null on signing failure instead of throwing, so a missing secret degrades the timeout feature in line with verifySessionTimeoutState rather than crashing authenticated requests; middleware and heartbeat skip the cookie write when null - heartbeat initializes a fresh signed state for a missing or session-mismatched cookie, mirroring middleware, instead of returning SESSION_EXPIRED during normal initialization - sessionStateMatchesUser treats an unresolved current session id as a mismatch for session-bound state so another session's cookie is never accepted on the userId fallback alone - drop aria-live from the countdown DialogDescription so screen readers are not interrupted every second Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f55bfc478b |
fix(bookkeeping): three static templates named accounts that break or mis-book (#1397)
Cross-checked all 86 booking templates (26 packs + 60 static) against BAS 2026.
The pack catalogue is clean; the static registry had three defects, none of
which any existing check could see.
Two named accounts that do not exist in BAS 2026, so account-backfill.ts cannot
seed them and EVERY booking through them failed with AccountsNotInChartError:
vehicle_parking 5614 -> 5619 the BAS 561x run skips 5614; 5619 is the
sibling "Ovriga kostnader for personbilar"
it_cloud_hosting 5421 -> 5420 BAS has no 5421; both sibling SaaS templates
already use 5420 Programvaror
One that posted successfully to the wrong account, which is worse because
nothing complained:
travel_hotel 5820 -> 5830 5820 is Hyrbilskostnader. The Hotell template
filed hotel nights under car hire, balanced,
and looked fine. 5830 is "Kost och logi".
An existing test asserted the 5820 behaviour, so the bug was pinned as
expected output. That assertion now reads 5830 and carries the reason.
Adds template-accounts-exist.test.ts so the 5614 class cannot return: every
account any static template names must resolve in BAS 2026, account numbers
must be strings, and a VAT-bearing purchase may not debit equity or revenue.
That last check is deliberately narrow: class 1 is legitimate, equipment_capital
debits 1250 Inventarier and reclaims VAT, which is how capex is booked. Verified
the guard fails on 5614 and passes on 5619 rather than trusting it.
The pack catalogue already had this check via scripts/validate-packs.ts. The
static registry had nothing, which is how it drifted.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a3d8fe733c |
fix(packs): representation template named itself avdragsgill while booking 6072 (#1396)
Raised by the Swedish compliance bot on #1395. The template called itself "Representation (avdragsgill, 25% moms)" and labelled its cost line "Representation avdragsgill", but booked to 6072, which BAS 2026 defines as Representation, EJ avdragsgill. The swedish-vat skill lists that exact confusion under Representation errors: "Not separating avdragsgill/ej avdragsgill on correct accounts (6071 vs 6072)". The account was right and the words were wrong. Meal representation stopped being income-tax deductible in 2017, so 6072 is where the cost belongs; only enklare fortaring (max 60 kr per person) is still deductible, on 6071. Renamed to "Representation, maltid (ej avdragsgill)" and relabelled to match. VAT moves from 25% to the 12% restaurang-och-catering rate our own static representation_external template in booking-templates.ts already used, so the two template systems stop contradicting each other, with the paired net ratio 1/1.12. A legal_note now carries the 300 kr per person VAT cap, which this format cannot compute because a template sees only a total and never a participant count, plus the reminder that the rate follows the underlag rather than the template. Correcting the record on one point: the seeded ratio 0.8 was NOT a phantom 80% deductibility rule, as the bot suggested and as I first repeated. 1/1.25 = 0.8, so it was the net-of-VAT fraction pairing with the 25% rate, and the arithmetic balanced. Only the naming and the missing cap were wrong. The ratio is now commented in the file so the next reader does not make the same misreading. Slug deliberately unchanged though it still reads "avdragsgill": a slug is an identifier, not a label (packs/README.md). Renaming it would retire this template and insert a new one, breaking every company's booking_template_usage history. The drift guard caught the rename, which exposed that the test joined packs to seeded rows by NAME. That is the same fragility that made pack_slug the sync's key, so INTENTIONAL_DIVERGENCES now records each entry's seededName explicitly. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
267ed6c1bb |
feat(mcp): expose the konteringspaket catalogue as a resource (#1395)
An agent proposing a booking currently invents the account numbers, and a plausible guess (6071 instead of 6072, or the full cost instead of the 80% deductible share) produces a verifikat that posts and is wrong. This turns that into naming a reviewed template. Read from packs/ rather than the database on purpose: legal_note has no column in booking_template_library, so the database copy cannot answer "when does this template apply", and that note is the one field an agent cannot derive from account numbers. Carries the amount maths explicitly (vat lines from vat_rate, everything else from ratio) with an instruction not to fudge an amount to force a balance, plus notes that entity_type is binding, that a company may hold templates beyond this list, and that posting goes through the journal-entry tools so period locks and the balance check still apply. A load failure returns an explicit error rather than an empty list: an agent shown 12 of 26 templates concludes the other 14 do not exist and hand-rolls accounts for them. Verified the packs are actually reachable at runtime before relying on this: a production build traces all 26 YAML files into the serverless function, so the loader is not a local-only convenience. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
281cb3989d |
fix(build): break Turbopack chunk-name hash collision from #1385's import edge (#1394)
Since the #1385 squash-merge every production build failed with 'Two or
more assets with different content were emitted to the same output path'
on [root-of-the-server]__1ge0sz5._.js: two distinct server chunk groups
(an AWS smithy helper chunk and the withRouteContext auth chunk) hash to
the same chunk name. The graph change that tipped the chunk layout into
the colliding state was entity-mapper.ts (arcim-migration extension
entry graph) importing lib/vat/supplier-invoice-line-checks, which
drags lib/money into the extension root.
Move normalizeVatRateToFraction into an import-free leaf module
(lib/vat/vat-rate-unit.ts), re-export it from
supplier-invoice-line-checks for all existing callers, and point
entity-mapper at the leaf. Behavior is unchanged (511 targeted tests
pass); the server chunk graph returns to the pre-#1385 shape that
builds cleanly. Verified: npm run build fails on
|
||
|
|
ff864ad3db |
feat(packs): sync system templates from packs/ instead of the frozen migration (#1390)
Phase 2b. The packs become the source of truth; the table stays the query surface, so the existing read path (one RLS query returning system + company + team templates) and every template id are untouched. pack_slug is the stable upsert key (migration 20260803230000, backfilled onto all 26 seeded rows). Matching on name instead would have meant that correcting a Swedish label looks like a new template, and because booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row would silently wipe every company's "recently used" history for it. For the same reason a removed pack DEACTIVATES its row rather than deleting it: the read path already filters is_active, so it leaves the picker while usage history survives. The sync fails closed. A pack that will not parse or validate aborts the whole run with zero writes, because a database left in a state no commit of the repo describes is worse than a stale one. An empty catalogue is treated as a broken deploy (packs/ not bundled) rather than an instruction to retire every system template. Runs as a daily cron rather than at boot: boot-time work would have every serverless instance racing to write the same rows, and would re-apply a bad catalogue continuously instead of once a day where it is visible. Idempotent, so a database already matching the packs performs zero writes. Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts: a partial unique index (one pack, one template), a format CHECK mirroring PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping pack_slug off company templates, where it would shadow the pack it collides with. Verified the upgrade path locally the way the pg-upgrade job will run it: base schema, seeded fixture company with posted verifikat, an existing company template, then this migration alone. 26 slugs backfilled, company template intact, upgrade assertions pass. This is that job's first real migration. Payloads are spelled out rather than spread so the phantom-column guard can check them, and docker/crontab.* are regenerated for the new vercel.json entry. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0ef3c03904 |
fix(salary): book net deductions as settlement lines so salary entries balance (#1374)
* fix(salary): book net deductions as settlement lines so salary entries balance Net deduction line items were skipped entirely in createSalaryEntry, so the credit side (2710 tax + 1930 net) fell short of the gross debit by exactly the deducted amount and the balance trigger rejected the voucher. Net deductions now book on their mapped settlement account (1613 advance repayment, 2794 union fee, 7385 benefit co-payment, 2799 other; explicit account_number overrides), aggregated and undimensioned like the other settlement legs. The default mapping in account-mapping.ts moves off 7210 so payslip lines, the booking preview and the voucher all agree. Fixes #316 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): address compliance review on net-deduction accounts Label 7385 with its BAS 2026 name (Kostnader för fri bil) instead of the benefit-generic Bilförmån, document why the single benefit-payment item type defaults to 7385 with per-line override for other benefit kinds, and add a repayment-direction test (positive net deduction books as debit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8443062b1f |
fix(vat): enforce fraction unit for supplier invoice vat_rate writes (#1385)
Closes the remaining #310 write paths: credit-note item copies (web, v1, pending-operations) and arcim-migration supplier imports now normalize vat_rate to the decimal-fraction unit before storage, and a NOT VALID CHECK constraint guards every new supplier_invoice_items row. Customer invoice items deliberately stay percent; legacy supplier rows are left untouched so posted-entry reversals reuse the exact original values. Fixes #310 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5b9605d8e9 |
fix(packs): repair the four broken system templates the validator found (#1388)
Phase 2a quarantined four defects rather than guessing at Swedish accounting. Each is now resolved against a domain source. KNOWN_BROKEN is empty. Löneutbetalning could never post. It debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, totalling 1.42x the amount, so the balance trigger would reject every entry built from it. Rebuilt per the swedish-payroll skill: Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld line is gone because vacation accrual is its own verifikat (7290/2920), and a legal_note now says the 30% split is schablon and must be adjusted to the actual skatteavdrag. Periodiseringsfond avsättning/återföring referenced account 2113. Per swedish-year-end-closing the year-tagged block is 2120-2129 (2126 = tax year 2026), so 2113 was the fund for tax year 2013: long since reversed and absent from BAS 2026. Both now use 2110 Periodiseringsfonder, which does not rot annually, with a legal_note pointing at the year-tagged accounts for a company that tracks funds per year. Preliminär F-skatt (EF) turned out to be RIGHT, and the reference was wrong. Account 2012 "Avräkning för skatter och avgifter" was simply missing from lib/bookkeeping/bas-data (the file jumps 2011 -> 2013), while the swedish-year-end-closing skill uses it in two places as an enskild firma equity sub-account. That is not cosmetic: account-backfill.ts only seeds accounts present in BAS_REFERENCE, so any entry touching 2012 failed with AccountsNotInChartError. Added it with the equity SRU code its siblings share, and a description separating it from 1630, which carries a confusingly similar name on the asset side. The port test now distinguishes deliberate divergence from accidental drift: a pack not listed in INTENTIONAL_DIVERGENCES must still match the seeded JSONB exactly, and a listed pack must actually differ, so neither an unnoticed edit nor a stale entry can survive. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8299ee9fb4 |
fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)
Completes the #985/#986/#987 caller sweep: categorize-core, v1 batch-categorize, pending-operation edits and the MCP categorize path now resolve the settlement leg from the transaction's cash account instead of inheriting a hardcoded or stale account. Extends the correct_entry preview with currency, tax and dimension line metadata so staged corrections preserve full line fidelity. Adds a read-only audit query and a runbook for reviewing and correcting historical mis-bookings via staged storno with explicit approval; no automated bulk mutation. Fixes #1001 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e40dc64485 |
feat(invoices): invoice list column sorting and row-level status styling (#1375)
* feat(invoices): add column sorting and row-level status styling to invoice list Client-side sorting on number, customer, due date, amount and status with Swedish collation, null-last ordering and stable date/id tie-breaks. Status chips move to the shared RowStatus descriptor so normal states stay muted and exceptions carry semantic color. Invoice fetch now pages past the PostgREST 1000-row cap via fetchAllRows so sorting covers the full list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): make rounded-amount sort assertions non-degenerate Distinct rounded totals now prove the comparator orders by displayed value; the rounded tie case is kept as an explicit tie-breaker test since integer rounding is monotonic and can only create ties, never reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a2f7132c94 |
fix(year-end): conservative historical repair for carried-forward 2099 (#1373)
* fix(year-end): conservative historical repair for carried-forward 2099 The steady-state year-end flow already reclassifies the opening 2099 (Arets resultat) to 2098 (Foregaende ars resultat) right after the opening balance is generated. Periods opened before that fix still carry the prior year's result on 2099. Add lib/core/bookkeeping/result-appropriation-repair.ts: a pure classifier plus assess/post helpers that auto-post the 2099 -> 2098 transfer only when it is unambiguous (open unlocked aktiebolag period, posted explicit opening_balance entry, active 2099/2098 accounts, no posted result_appropriation yet, current posted 2099 still equal to the explicit opening amount, and no other entry touching 2099). Everything else is skipped or listed for manual review; nothing is reconstructed from cumulative history. All writes go through the bookkeeping engine. Rework scripts/repair-result-appropriation.ts into a thin CLI over the library: global/company/period dry-runs, and commit mode that requires one exact --company-id, --period-id, and --user-id and re-assesses immediately before posting. Fixes #735 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(year-end): require company membership for repair attribution Compliance review (ASVS V8.2.1): commit mode accepted any --user-id and attributed the posted journal entry to it unvalidated. The service-role client bypasses RLS, so nothing downstream would catch an outsider uuid. postHistoricalResultRepair now verifies a company_members row for the target company before posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): reference the opening-balance underlag on the repair verifikat Swedish compliance review (BFL 5 kap 6-7 §§): the historical repair entry validated against a specific opening-balance entry but never recorded it. Link it machine-readably via source_id and human-readably in the entry note ("Underlag: ingående balans, verifikat A1 (<id>)"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(year-end): harden repair CLI arg parsing, pagination and exit code CodeRabbit review on #1373: - arg() rejects flag-shaped or missing values instead of silently consuming the next flag as an id - global company and period scans paginate via fetchAllRows() so deployments past the PostgREST 1000-row cap are fully covered - exit code is non-zero when any period failed to list, assess or post 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> |
||
|
|
5d7952a01e |
feat(mcp): model-free document upload via signed URL (#1378)
* feat(mcp): model-free document upload via signed URL (#748) Adds gnubok_create_document_upload + gnubok_complete_document_upload so document bytes reach storage through a short-lived signed PUT URL and never pass through the model context. Fixes silent base64 corruption on real-size PDFs and the context blowup on batch uploads. - pending/ staage keys with TTL cleanup; completion validates magic bytes + SHA-256, moves bytes to the WORM key and adopts the reserved UUID as document id, making retries and concurrent completions idempotent - legacy gnubok_upload_document kept for clients without file access, description now points to the signed-URL pair; shared mime resolution and inbox-item creation extracted - both new tools mapped in TOOL_SCOPE_MAP (transactions:write) and MCP_TOOL_CAPABILITY_MAP (ai) so the paywall and scope gates hold - payload guard ceiling 58.5K to 59K after trimming the create tool's outputSchema to upload_id/upload_url/expires_at Fixes #748 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): satisfy capability-map lock and phantom-column scanner The exact-entries lock in capability-maps.test.ts now includes the signed-URL pair as dispatch-only AI tools, and the inbox insert uses a literal payload (explicit UUID instead of a conditional spread) so the no-phantom-columns scanner can resolve every column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
00d4c8a49e |
feat(invoices): ROT/RUT payout file dialog and file guards (#1380)
* feat(invoices): ROT/RUT payout file dialog and file guards Rebuild the UI for the existing headless HUS V6 payout-file flow (demanded via #789): a dialog on the invoices page to pick eligible paid ROT/RUT invoices, generate the XML, download it and track request status. Adds file-level guards from the Skatteverket spec: future payment dates blocked, one file per payment year, max 100 cases per file, with per-invoice blocker messages. Submission stays manual (upload + sign in the SKV e-service); no direct submission API exists. Fixes #789 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): compute rot-rut gating date in Europe/Stockholm The candidate and begäran date defaults used the UTC calendar day, which near midnight Swedish time could wrongly block or admit an invoice via FUTURE_PAYMENT_DATE and shift the 31 January deadline warning. Use getSwedishLocalDate() like the bookkeeping engine. Raised by the Swedish compliance review on PR #1380. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
df34cae9bf |
feat(packs): konteringspaket as validated data files (phase 2a) (#1386)
* feat(packs): konteringspaket as validated data files, ported losslessly The 26 system booking templates lived inside migration 20260413160000. Under the never-modify-a-shipped-migration rule that froze them: correcting a wrong BAS account or a Swedish typo needed a whole new migration, and nothing checked that a seeded account existed in the chart or that a template balanced. #1321 was exactly that failure with seeded chart names. They are now one YAML file per pattern under packs/, with a Zod contract and a CI gate. A correction becomes a one-line edit plus a green run. The port is proven lossless, not asserted. The test fixture was read out of a Postgres with all 548 migrations applied, so it is the exact JSONB production holds; lib/packs/__tests__/port-is-lossless.test.ts asserts the YAML reproduces it by value. Phase 2b can swap the seeded rows for the loader as a no-op. The gate checks what makes a pack CORRECT, not just well-formed, because #1321 was structurally valid and still wrong: every account must exist in BAS 2026, and every pack must balance at five probe amounts through the real applyTemplate() rather than a reimplementation. Account numbers validate through lib/invariants, so a pack cannot disagree with the API or the SIE importer about what an account number is. Doing that immediately found four pre-existing breakages in the shipped templates: loneutbetalning debits total 1.42x the amount against a 1.0 credit: it can never post periodiseringsfond-avsattning-ab account 2113 is not in BAS 2026 and is not periodiseringsfond-aterforing-ab seeded into any company chart preliminar-f-skatt-ef account 2012, same problem These are quarantined in KNOWN_BROKEN, not fixed and not hidden: a quarantined pack's findings are warnings, any NEW finding fails the build, and the validator fails if a quarantined pack turns out to be clean, so the list may only shrink. Each is a Swedish accounting content change to a user-facing template, which deserves its own review rather than riding along inside a file-format change. Five shipped descriptions contain em dashes, preserved verbatim and pinned by a test: a lossless port must not silently rewrite user-visible strings. js-yaml is promoted from a transitive dependency to a declared one (MIT, already in node_modules), so the catalogue does not depend on it by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(deps): regenerate package-lock.json with npm 10 to match CI `npm ci` failed on every job with "Missing: @swc/helpers@0.5.23 from lock file". The lockfile was written by local npm 11.6.0; CI runs npm 10.8.2 on node 20, and npm 11 emits a tree npm 10 reads as out of sync. Regenerated with `npx npm@10 install --package-lock-only`, which cuts the diff from a sprawling rewrite down to the three entries this branch actually adds (js-yaml, @types/js-yaml, and the @swc/helpers entry npm 11 had dropped). Verified with `npx npm@10 ci --dry-run`. This is the documented gotcha for this repo: regenerate lockfiles with npx npm@10, never with a local npm 11. 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> |
||
|
|
c1888fcd5e |
feat(inbox): make custom-domain dialog ship-ready while gate stays off (#1377)
* feat(inbox): make custom-domain dialog ship-ready while gate stays off Polish the gated custom inbox-domain dialog from issue #814: full sv/en i18n via next-intl, admin/owner role gating on manage actions, explicit load-error state with retry, resilient clipboard copy with failure toast, and locale-aware verified-date formatting. The INBOX_CUSTOM_DOMAINS_ENABLED gate (product decision 2026-07-02) stays in place: routes still return 403 FEATURE_DISABLED when off and the workspace entry point remains removed. Enabling is a deliberate release decision. Refs #814 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(inbox): narrow useFormat locale to ErrorLocale for error messages The zero-extensions build failed type check: useFormat() returns a plain string locale while getErrorMessage expects ErrorLocale. Use the established repo idiom (cast once, reuse) as in DimensionsManager and AgentSkillsPanel, and point the useCallback deps at the derived value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd7d7f52b9 |
feat(invoices): per-recipient email delivery outcomes (#1384)
* feat(invoices): per-recipient email delivery outcomes Resend delivery webhooks identify affected addresses in data.to, so one message with CC recipients can carry independent To/CC outcomes instead of masking the failing address into the aggregate reason text. - new apply_invoice_delivery_provider_event RPC merges each reported recipient onto its immutable To/CC position with the same rank and timestamp ordering as the aggregate status (retry and out-of-order safe) - recipient map is PII-free: keyed to:N / cc:N, BCC and unmatched recipients are never represented, and the map is cleared on PII redaction - delivery summaries, API route and MCP tool expose the sanitized map; the route re-sanitizes as defense in depth - UI shows a per-recipient status list under the aggregate outcome The prod ops check in issue #1350 (webhook registered in Resend and RESEND_DELIVERY_WEBHOOK_SECRET set in Vercel) cannot be verified from the repo and remains a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): commit provider event before cross-context read The BCC-leak test applied the event inside the rollback-scoped service role helper and then asserted through a separate member context, so the applied status was rolled back before the read. Use the committing runAsServiceRole helper for the apply, matching how the summary read is performed in its own context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bb1eddcccf |
fix(salary): exempt F-skatt compensation from arbetsgivaravgifter (#1372)
* fix(salary): exempt F-skatt compensation from arbetsgivaravgifter calculateAvgifterRate() never checked fSkattStatus, so F-skatt earners were charged the standard 31.42% employer contributions even though they pay their own egenavgifter. This overstated employer cost by ~31% and booked incorrect 7510/2731 entries. Add an early return in calculateAvgifterRate for f_skatt (rate 0, category exempt) before any age-based rules, and zero the avgifter basis in calculateSalary so salary reports and AGI totals do not carry a false contribution basis. The FK011/FK131 AGI rendering defect stays scoped to issue #315. Fixes #314 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(salary): assert full exempt return contract for F-skatt CodeRabbit review: calculateSalary does not read amount/basis from AvgifterCalculation, so the direct-return test must pin both to zero to catch a regression in the exempt early return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
24911abde0 |
feat(import): support Wise balance statements (#1368)
* feat(import): support Wise balance statements * fix(import): fail closed on ambiguous Wise rows * fix(import): guard Wise statement netted-fee assumption with running-balance continuity check Swedish accounting review asked whether balance-statement Total fees is netted into Amount. It is: Running Balance moves by exactly the signed Amount per row, so a separate fee row would double-count the cost. Codify the assumption with a pairwise continuity warning (order-agnostic, chain resets across skipped rows) and document the decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): cap bank-import validation payload and harden issue assertion CodeRabbit review: bound the VALIDATION_ERROR issues array to 20 entries with issue_count carrying the full total, so a large malformed file cannot balloon the response or log sink. Gate stays format-agnostic on purpose: error severity means do-not-ingest for every parser, and no non-Wise parser emits per-row errors alongside parsed transactions today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0510d4c13f |
fix(sync): stop expired trials from starving automatic bank and skattekonto sync (#1376)
The bank sync and skattekonto sync crons fetched the 50 oldest connection/token rows and only then checked entitlements per item, so expired-trial rows permanently occupied every batch slot and entitled companies were never synced automatically. Fetch all candidate rows, resolve capability grants in bulk via the new getCompanyIdsWithCapability() (company and firm grants cascade, expired grants excluded, explicit per-company disable wins), and apply the 50-item run cap after filtering. Entitlement query failures now fail the run instead of silently skipping every company. Fixes #563 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c9625fa45c |
fix(salary): book lonevaxling pension provision from frozen run snapshot (#1382)
The book flow never populated pension_contribution/pension_slp, so a gross_deduction_pension line item reduced the salary entry but the 7410/2740 pension provision and 7533/2514 SLP lines were never posted. Derive both at the createSalaryRunEntries boundary from the run's frozen calculation_params.slpRate snapshot so the dashboard, MCP and v1 booking paths all emit the pension verifikat, and reuse the exact 1.058 factor via calculateLoneVaxlingPensionProvision shared with the planning calculator. Fixes #317 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6782da3e9e |
feat(bokslut): calculate and book overavskrivningar (2150/8850) (#1379)
Add an automatic excess-depreciation calculator for machinery and equipment under IL 18 kap: 30-rule and 20-rule residuals (fiscal-period aware for short and long years), ledger vs asset-register reconciliation, fail-closed blocking states, and a signed proposal that books via the dispositions flow (8853/2153). Releases of an over-target reserve are mandatory and not overridable; increases are optional and capped server-side. Fixes #323 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>
|
||
|
|
ef237351b6 |
test(db): verify annual-report compliance stack (#1367)
Closes #1352 |