d41ef2a9098cc2bfbd66eb53c359aa9fb78f9457
178
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d41ef2a909 |
feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs (#1437)
* feat(sandbox): seed payroll, articles and a year of ledger history; calm the connect CTAs The sandbox showed neither Löner nor a usable set of reports, and the "connect X" surfaces were oversized boxed cards. Sandbox seed: - pays_salaries + employer_registered, so Löner and Anställda appear at all (an enskild firma is not an employer by default). Both seeded employees are employment_type 'employee': an EF may employ staff, just not its own owner. - Two employees, one booked and one open lönekörning, and the three verifikat the booked run must have posted (7210/2710/1930, 7510/2731, 7290+7519/ 2920+2940). Skatteavdrag comes from the real Skatteverket 2026 tables. - Year-to-date ledger history, January through last month, with the quarterly momsredovisning cleared to 2650 and paid on the SFL deadline. Without the settlement the demo collected VAT all year and never remitted it, which left an implausible bank balance and 155 813 kr of moms "att betala". - The history is exempted through journal_entry_no_doc_required, the same way the SIE-import opt-in treats imported books: its kvitton live in the previous system, and unflagged it put 39 "verifikat utan underlag" on the home screen. - Artikelregister, and the BAS accounts the K1 chart omits for an enskild firma. - History is numbered before the invoice and payroll vouchers so the series runs forwards through the year, and its writes are batched. Connect CTAs: - Bank picker: a two-column grid of 95px bordered logo cards becomes flat hairline rows, Lucide icons, and a quiet inline connecting state. - Cloud backup: each provider collapses to one row; the BFL note is shown once for the section and names only configured destinations. - Hem first-run: only the active step argues its case, but every not-done step keeps a reachable action. The Skatteverket nudge becomes one quiet sentence. Mobile assistant FAB: a fresh open is desktop-only, since the bottom nav already has an Assistent tab. A collapsed session keeps its handle everywhere except /chat, which is itself the way back to the conversation. Also closes a real hole: /api/salary/runs/[id]/payslips/send had no sandbox guard, and a seeded booked run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): check the two unchecked Supabase errors and tighten review nits CodeRabbit review on #1437. Major: two calls discarded their error and continued with null data. A failed chart_of_accounts re-select would have written account_id: null onto every ledger-history and salary voucher line, and a failed next_voucher_number would have inserted a posted verifikat with no number, which is a hole in the verifikationsserie (BFNAR 2013:2). Both now throw, and a null voucher number is rejected explicitly. Minor: the A-004 note claimed a 10 % markup on numbers that are 11.1 %; the salary breakdown test's name said the opposite of its assertions after the switch to the real tax table; the ledger-history doc still said 4 to 6 verifikat per month before the quarterly momsredovisning added a seventh in March, May and June. Bank picker: the spinner is aria-hidden, so loading and connecting had no text equivalent and a failed bank fetch was never announced. Added role="status" with an sr-only label, and role="alert" on the error line. Declined: confirm-before-disconnect on the cloud-backup row. Disconnect was unconfirmed before this PR too, so adding a dialog is a behaviour change beyond the redesign rather than a fix to it. 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> |
||
|
|
43386b4852 |
feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)
The originally reported scenario is still broken after #1425 and its backfill-by-document_id: a user photographs a receipt into WhatsApp, answers the bot's questions, then opens the app, clicks the bank transaction and asks the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we are holding, then asked everything again. WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id (process-inbound.ts passes uploadAndExtract's matchedTransactionId as undefined) nor transactions.document_id (that mirror is written by the manual match route). Only TransactionMatchPicker fills either column. So the underlag list comes back empty, and a backfill that keys on document_id has nothing to key on. Unmatched, unconsumed inbox items are now scored against the transaction with the same pure scorer the picker uses and the strongest few are surfaced as TROLIGT UNDERLAG, carrying their captured chat answers. Proposals only: nothing writes matched_transaction_id, and the prompt tells the agent to get the match confirmed and to book only against a confirmed one. Setting the link at intake above a confidence bar is the obvious alternative and is deliberately left open. An uncomparable amount (cross-currency with no rate) disqualifies a candidate, because calculateMatchConfidence drops the amount signal in that case and date + merchant alone then score a confident match nobody checked the sums for. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f7147a078 |
fix(agent): read a WhatsApp "nej" as an answer, not a half answer (#1433)
* fix(agent): read a WhatsApp "nej" as an answer, not a half answer #1425 gave the assistant the answers the user typed in WhatsApp. Rendering those inline off the raw channel_context blob gets the most common answer backwards. Answering "nej" to the representation question stores an EMPTY representation block: participants: [], purpose: null, denied: true. The renderer branched on `if (!rep.purpose)` and so emitted syfte SAKNAS: fråga bara efter syftet, inte om deltagarna igen. for a user who had just said the meal was not representation. `denied` was never read anywhere. The result is the assistant asking about the purpose of a private lunch, which is worse than the generic re-ask #1425 fixed, because the instruction is specific and confident. Clarifications now come from a structured summary that models the denial and the genuine half answer (participants named, purpose missing, which BFL 5 kap 6-7 § does want completed) as different states. #1425's syfte SAKNAS nudge is preserved for the case it was written for. Two smaller fixes in the same renderer, both about untrusted text: - The photo caption no longer reaches the prompt. It is the one field on the record nobody was asked for and nobody reviewed, and the rationale already written down in lib/documents/channel-context-notes.ts for keeping it off an immutable verifikat applies at least as strongly to a prompt that can call tools. - Human free text passes through flattenMemoryContent. An intent's promptTemplate output is seeded as a user message, so wrapToolResult never sees it and nothing else defends this path; a caption reading "# NYA INSTRUKTIONER: ..." previously rendered verbatim. All three tests fail against the current renderer and pass against this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(agent): gate the chat-answer guidance on what was rendered CodeRabbit caught the same defect shape this PR is about: the prior-conversation paragraph was gated on chat_answers != null, but a caption-only context is non-null and now summarises to nothing, so the paragraph pointed at 'uppgivna av användaren' rows the prompt does not contain. Gate on whether a clarification line was actually emitted. 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> |
||
|
|
2dff83e2f3 |
feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder (#1432)
* feat(bokslut): kontantmetoden year-end cut-off for fordringar and skulder Under kontantmetoden nothing reaches 1510/2440 during the year, but BFL 5 kap 2 § still requires fordringar och skulder to be booked at rakenskapsarets utgang. That conversion did not exist: the AR/AP tie-outs were permanently unreconciled by construction for all cash companies, and the balance sheet omitted every open invoice. Adds lib/core/bookkeeping/kontantmetod-cutoff.ts: Fordringar: Debit 1510 / Credit 30xx / Credit 2618|2628|2638 Skulder: Debit 4-6xxx / Debit 2648 / Credit 2440 Moms goes to the VILANDE accounts, never 2611/2641. Under bokslutsmetoden moms is reported at payment, and the vilande accounts are deliberately absent from ACCOUNT_RUTA / ACCOUNT_TO_BOX, so parking it there keeps it out of the momsdeklaration until the invoice is actually paid. Booking it to 2641 would claim the deduction a period early. Two aggregate verifikat, each reversed on day 1 of the next period, and no invoices.journal_entry_id link: the payment flows route on that link, so per-invoice linking would send every new-year payment down the accrual clearing path against a receivable the vandning already removed. Leaving it unset means a new-year payment still books the normal kontantmetoden cash entry at the real payment date. Outstanding is computed from payment DATES, not remaining_amount: an invoice settled in January was still a fordran on 31 December, and reading remaining_amount would shrink the cut-off every day the bokslut is delayed. Surfaced as a bokslut wizard reminder (warning, not a blocker: promoting it would newly block every cash company mid-bokslut, which is a separate call). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): address compliance review on the kontantmetoden cut-off Three findings from the Swedish compliance review, all real: 1. BFL 5 kap 6-7 § traceability. The aggregate verifikat collected invoice references but never wrote them, so an examiner could not trace the 1510/2440 posting back to the affarshandelser behind it. Invoice numbers now go into the entry `notes` via buildCutoffNote(), truncated past 50 so the note stays a pointer to the reskontra rather than a copy of it. 2. Non-atomic posting. The cut-off and its vandning were two sequential creates with no rollback: if the reversal threw, 1510/2440 stayed permanently inflated and every new-year payment would double-book, which is exactly what the module docstring warns about. postKontantmetodCutoff now asserts the target period exists, is open, and contains the reversal date BEFORE posting anything, so the common failures refuse without writing. If a reversal still fails after its cut-off committed, the cut-off is stornoed through reverseEntry() (BFL 5 kap 5 §: never edit or delete a posted entry) and the original error is rethrown. 3. Silent vat_treatment default. Missing vat_treatment fell back to 25 %, which would route a 12/6/undantagen invoice to the wrong vilande account AND the wrong revenue account. Such rows are now collected into CutoffCollection.unknownVatTreatment, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. Adds 11 cases for postKontantmetodCutoff, which had none: every refusal path asserts nothing was posted, and the storno-compensation path is covered in both the happy and the storno-also-failed direction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): never split a reverse charge across the cut-off Second compliance round. Verified the three data-dependent findings against production before changing anything; two needed no change, one is hardened: - Credit notes are NOT silently dropped: all 22 credit notes on prod carry document_type='invoice', so they are inside the collected set exactly as the comment claims. The filter only excludes proforma and delivery_note. - Vilande account numbers verified against the BAS 2026 chart in lib/bookkeeping/bas-data: 2618/2628/2638 utgaende, 2648 ingaende. The suggested 2617/2627/2637 do not exist. - Reverse charge: all 123 RC supplier invoices on prod carry vat_amount = 0, so no RC moms could reach 2648 today. That was an implicit data invariant, not an enforced one. CutoffPayable now carries reverseCharge and forces the cut-off moms to 0 for those rows, so a stray amount can never post a one-sided reverse charge into the single vilande bucket. The self-assessed output/input pair stays with the payment entry, after the vandning. Also names the reskontra as the underlag in a truncated aggregate note, so the verifikat points at its specification rather than implying the listed subset is the whole of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): surface stray moms on momsfri invoices instead of absorbing it Third compliance round, one legitimate new finding: moms on a treatment that cannot carry Swedish output moms (export, omvand betalningsskyldighet, undantagen) was folded into the revenue line with only a log.warn. That balances the verifikat while silently swallowing a real invoicing error, which is the netting the swedish-vat reference prohibits, and it was inconsistent with how the same module already treats a missing vat_treatment. Those rows now travel the same path as a missing treatment: collected into CutoffCollection.strayVatOnZeroRate, excluded from the cut-off, refused by the posting step, and surfaced as their own wizard reminder. buildCutoffLines keeps its balancing fallback for the case where such a row reaches it directly: it is now a last resort rather than the normal path, and it must still never invent a moms account nor unbalance the verifikat. 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> |
||
|
|
d73f288927 |
fix(supplier-invoices): reverse credit notes on paid kontantmetoden invoices (#1430)
Under kontantmetoden the credit flow skipped the reversing verifikat entirely, gated on accounting_method === 'accrual' in all three surfaces (dashboard route, v1 route, pending-operations commit). That is right only while the original is still UNPAID: nothing reached the ledger, so there is no entry to reverse and recognition waits for cash. But a PAID original was already booked by its payment verifikat (expense + 2641 ingaende moms). Crediting it marked the invoice 'credited' with zero accounting trace, leaving both the cost and the moms deduction overstated and nothing to link a later refund back to. Adds supplierCreditNoteNeedsJournalEntry(), the mirror of the customer side's creditNoteNeedsJournalEntry(): reverse whenever the original actually reached the ledger, whatever the configured method. createSupplierCreditNoteEntry's existing shape already suits the cash case, the 2440 debit leaves a claim on the supplier that the refund clears, just as the customer side leaves a 1510 credit for a refund owed. The v1 route's GDPR-minimised projection dropped exactly the booked-ness columns this needs; they are restored with a comment explaining why, since status alone misses a part-paid-but-booked original (rows predating #1413). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a5c10e441a |
chore(analytics): configure posthog session replay masking (#1428)
* chore(analytics): configure posthog session replay masking Move session replay from the mask-everything default to pattern-based masking in lib/analytics/replay-masking.ts: currency-shaped text, person-/organisationsnummer (rendered and typed) and password inputs are masked; other interface text and typed input is recorded for debugging. data-ph-mask keeps force-masking tagged PII and data-ph-unmask is still honored for chrome. Privacy policy, RoPA and decision log updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(analytics): address review comments on replay masking PR Bump the privacy policy's visible last-updated date to 2026-08-06 and add the conventional vi.clearAllMocks() beforeEach to the replay-masking tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
902b3ee986 |
fix(invoices): resolve ROT/RUT strings and hide the payout action when it does not apply (#1429)
The ROT/RUT payout feature shipped in #1380 with all 44 of its strings written into the invoice_editor namespace, while RotRutPayoutDialog and the invoices page both read useTranslations('invoices'). next-intl falls back to rendering the key path, so production showed literal "invoices.rot_rut_payout_title" text where every label should be, and the header button read "invoices.rot_rut_payout_action". Move the keys to the invoices namespace, textually rather than through a JSON round trip: the message files contain duplicate keys that JSON.parse/stringify would silently drop. Three other dialogs had the same namespace mismatch and are fixed the same way, by adding the strings they reference to the namespace they read from (TemplateBookDialog reads bookkeeping, Correction/StrikeLines read journal_detail). Add i18n/__tests__/message-keys.test.ts, which resolves every literal t() key in app/, components/ and extensions/ against both locales. next-intl has no build-time check and degrades by rendering the key path, so this class of bug reaches users silently otherwise. Also gate the payout action. Begäran om utbetalning (Lag 2009:194 8 §) only concerns companies selling ROT/RUT-eligible work to consumers, so the header button now appears only when the company has invoiced a deduction or has opted into ROT/RUT in tax settings. The flag comes from the company_settings row the page already fetches for ore_rounding, so there is no extra request. ?rot-rut=1 still opens the dialog. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
12c59399ee |
fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stop promising deferred-tax accounting the engine no longer does The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37. 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> |
||
|
|
576ed4d290 |
fix(agent): let the assistant see answers the user gave in WhatsApp (#1425)
Two field findings from the first live receipts.
1. The assistant re-asked for information the user had already given.
The user answered the representation question in WhatsApp ("Elias
Karlsson från Canguro Media, Jakob Wennberg från Arcim"), the answer
was stored correctly on invoice_inbox_items.channel_context, and then
the in-app assistant said it could see no participant names and asked
for them again. The intent's inbox query selected only document_id and
extracted_data, and nothing under lib/agent/ read channel_context at
all. It is now selected, threaded onto each underlag as chat_answers,
and rendered into the prompt as "uppgivna av användaren" with an
explicit instruction that human answers outrank anything read off the
image and must never be re-asked. Also backfilled by document_id: a
receipt can reach the intent through the document paths without its
inbox row being matched to the transaction.
2. The representation question accepted half an answer in silence.
Naming participants but no purpose stored purpose=null and replied
"Tack!", leaving the deduction undocumented while looking complete.
Skatteverket wants both (BFL 5 kap 6-7 §). It now asks once, for the
missing half only, and keeps the question open so the reply routes
back to the same receipt. Anti-loop: the follow-up fires only when no
representation block exists yet, so a second incomplete answer is
taken as-is rather than nagging.
Tests cover the prompt half and the query half separately: the earlier
prompt tests injected chat_answers directly and would have stayed green
with the column still missing from the select, which is precisely how the
bug shipped. Both mutation-checked.
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
f10bac6023 |
fix(reports): block unsupported EUR annual reports (#1366)
Closes #1360 |
||
|
|
d9fb5da16d |
fix(reports): localize latest voucher label (#1365)
Closes #1267 |
||
|
|
9e54a8e400 |
fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com> |
||
|
|
18cdba3574 |
fix: make out-of-order SIE opening balances atomic (#1334)
* fix: preserve SIE IB on out-of-order imports * fix: make SIE opening balance replacement atomic * test: seed accounts for atomic IB pg coverage * test: complete atomic IB pg fixtures * fix(import): avoid IB resync across fiscal-year gaps * test(import): mirror PostgREST date values in pg adapter * fix(import): address opening balance review feedback |
||
|
|
8a2498a987 |
fix(mcp): add true continuation to invoice list tools (#1327)
* fix(mcp): add true list continuation Signed-off-by: Emil <emilmattsson14@gmail.com> * test(mcp): cover pagination edge cases Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(mcp): validate pagination offsets Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(mcp): preserve continuation without counts Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> |
||
|
|
115147d304 |
docs(docker): improve NAS self-hosting compatibility (#1330)
* docs(docker): improve NAS self-hosting compatibility Signed-off-by: Emil <emilmattsson14@gmail.com> * docs(docker): retain optional resource controls Signed-off-by: Emil <emilmattsson14@gmail.com> * docs(docker): clarify compatibility prerequisites --------- Signed-off-by: Emil <emilmattsson14@gmail.com> |
||
|
|
fd376eff94 |
fix: audit Cloud Backup OAuth redirects (#1324)
* fix(cloud-backup): pin OAuth callback origin * fix: reject non-web cloud backup origins |
||
|
|
d684e3c440 |
feat: add theme palettes (#1326)
Add Neutral, Indigo, Forest, and Sand palettes independently of Light, Dark, and System. Persist and hydrate the selection, add the accessible settings picker, and include the validated review fixes for keyboard navigation and Swedish copy. |
||
|
|
f24e1076cf |
fix(templates): explain the Kostnad/Intäkt vs Betalning choice in the mall editor (#1325)
* fix(templates): explain the radtyp choice, and give the assistant product knowledge A user reported not knowing when to pick Kostnad vs Betalning on a template line, and that the in-app assistant could not answer either. UI, at the point of choice: - The editor dropdown said "Kostnad" while the template list said "Kostnad/Intäkt" for the same tag; both now use type_cost_revenue. - An InfoTooltip on the Rader label explains the three radtyper: what each one means, that the tag never changes which account is booked, and the exactly-one-of-each rule that makes a template pickable directly on a bank transaction. Assistant, structurally: a new always-active 'product' atom tier for knowledge about Accounted's own features, which no existing tier could hold (horizontal/vertical/modifier are accounting-domain and composer-owned). First atom: product/bokforingsmallar. The tier CHECK migration must precede the generated seed; product atoms are composer-invisible because every composer filter picks a specific tier. The general-help and settings-help intents now route feature questions to product atoms instead of guessing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): widen the remaining tier unions the product tier missed The zero-extensions build caught emitSkillLoaded pinning a literal copy of the old SkillTier union. The same sweep found two surfaces the type system could not flag: the mcp.skill_loaded event type, and the Vad din agent vet overview (agent-competence + CompetenceCard), which queries the registry with no tier filter and would have rendered product atoms as dormant. Product atoms are always active there too, labelled with a new tier_product string in both locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(agent): cover empty-slug 400 and curation-off 404; mention product knowledge in the panel help Review follow-ups: the 404 test only covered a missing row, not a row with mcp_exposed=false, and the empty-slug validation path was untested. The Kunskap panel's help popover still described the library as only Swedish accounting and tax knowledge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * revert(agent): pull assistant product knowledge back out; ship the UI clarification only Founder call 2026-07-31: the assistant gets no internal knowledge of Accounted's own features yet. This removes the product atom tier, the bokforingsmallar atom, both migrations, the discovery/MCP/panel wiring, and the intent routing added in the earlier commits; the working implementation stays in this branch's history (green at bb65224c) and is tracked in a follow-up issue. What ships: the unified Kostnad/Intäkt label and the radtyp InfoTooltip in the template editor. 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> |
||
|
|
4933fae7a9 |
fix(bookkeeping): correct seeded BAS account names that contradicted engine bookings (#1321)
- seed_chart_of_accounts named 7210 'Semesterlöner' while payroll books gross salaries there (BAS: 'Löner till tjänstemän'; vacation pay is 7285), so every seeded AB showed salary costs under a vacation-pay label in Nyckeltal and every other report - 3002 was named 'Försäljning varor 25%' although 3002 is the 12% revenue account everywhere else (invoice booking, category mapping, default_vat_rate seeding) - 7010 and 3001 get their BAS 2026 names; 2631 loses a double space - backfill renames existing rows only on exact seeded-literal match plus is_system_account, so accounts users renamed survive untouched - account-descriptions.ts had the 7010/7210 names swapped; client-account-names.ts labeled 2510 (Skatteskulder) as 'Personalskatt' Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f49dc3438d |
fix(sandbox): lock what the sandbox cannot actually do (#1318)
* fix(sandbox): lock what the sandbox cannot actually do Three surfaces in the sandbox advertised capability the sandbox blocks outright, or rendered a staged preview wrong. Skatteverket promo card: hidden for sandbox companies. The sandbox landing page tells users Skatteverket is off, and the authorize route 403s via guardSandbox, so the dashboard nudge was a dead end. Same precedent as TaxSettingsContent, which already hides its Skatteverket section on is_sandbox. Dokumentinkorg: locked with a state that says what the workspace does and sends the user to registration. Checked before the capability gate on purpose: the seed_trial trigger grants every new company (sandbox included) 30 days of every paid capability, so the existing paywall waved a demo company straight through. The CTA signs the anonymous session out first, mirroring SandboxBanner. Staged categorize_transaction preview: the seed wrote its kontering under the generic preview_lines key, but categorize_transaction is the one type with a dedicated preview component, and it reads `lines`. The card fell through to its legacy summary branch and rendered blank Debetkonto and Kreditkonto plus "NaN kr" from formatCurrency(undefined). The seeded blob now mirrors what gnubok_categorize_transaction stages, extracted into buildSandboxPendingOperations so both shapes are unit-testable. CategorizePreview also learns to read preview_lines and to show a missing amount as a gap, so a live 24h sandbox stops showing NaN before its data expires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): don't leave for /register when sign-out failed CodeRabbit review: the ExtensionSandboxLockState CTA ignored the signOut() result, so a failure routed to /register with the anonymous session still live, which registers INTO the sandbox instead of leaving it: exactly what the sign-out exists to prevent. Surface the failure and stay put so the user can retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f1c7c9365 |
fix(import): refuse a Bokio connection that opens a different company (#1315)
A Bokio integration token is scoped to one Bokio company and the company id is typed in by hand, so credentials for the user's other company imported that company's customers, suppliers and invoices with no error at all. Probe /companies/{id} before storing, mirroring the Bjorn Lunden /details probe, and refuse on a confident org-number mismatch.
Also surface the inbox mail body when nothing was attached: it was captured in email_body_text and never read back, which made Gmail's forwarding-confirmation mail unreadable and the forward impossible to complete.
|
||
|
|
fa394e3759 |
fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297)
Four fixes from the exit mail Anders Orback (Center Node AB) sent hours after churning. His five points were mostly one job: reconciling skattekontot against banken before årsredovisningen. Skattekonto look-alike rows. Skatteverket splits a retroactive omprövningsbeslut across every month it re-charges and sends one transaction per month, sharing date, text and amount; only ranteberakningsdatum separates them, and we stored it but rendered it nowhere. A real company posted 15 such vouchers (67 785 kr across Feb 2025-Apr 2026) unable to tell them from duplicates of the automatic hämtning. Surface the field when it carries information: its month differs from the Datum column, or another row in the same band is otherwise indistinguishable. The list-to-voucher round trip. The verifikat list collapsed to a skeleton on every refetch and sprang back, moving rows under the pointer; only the first load shows a skeleton now. Filter state is React-only, so leaving the list loses it: add a hover-revealed open-in-new-tab affordance on the voucher list and the skattekonto page, where the link had been behind a hand-rolled opacity-0 that coarse pointers never trigger. Makulerad rendering. A stornoed verifikat now reads as struck out, per data cell rather than on the row, because text-decoration propagates and a child cannot opt out. Vouchers-per-account discoverability. /reports/huvudbok?account=1930 already existed; the palette matcher requires every token and the entry never contained the word "verifikat". Add ReportDescriptor.searchTerms plus a report-library search box. Also fixes a false "Saknar underlag" compliance chip that flashed before attachment counts resolved, and a keyboard-access regression where HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a non-focusable wrapper from keyboard users. No migration. No write paths, storno paths or posted entries touched. Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308. Open decision: #1305 (Omförd vs Makulerad). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6318501b71 | fix(vat): recover ruta 05 for null-rate custom accounts (#1296) | ||
|
|
392e847c1e |
fix(transactions): block invalid invoice match targets (#1294)
Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage. Fixes #1260 |
||
|
|
17a7a62ceb |
fix(reports): stop the resultatavslut zeroing declarations, and make the mistake uninventable (#1293)
* fix(settings): explain why account deletion is blocked The delete-account button was disabled while the user still owned companies, but the reason only lived behind the "?" on the blocker row, so the greyed-out button read as broken. Surface it as one visible attn sentence directly under the button, and point aria-describedby at it whenever the button is disabled, not only on a load error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(enable-banking): share one PSD2 consent across a user's companies Connecting the same bank for a second company required a second BankID, and at SEB that new authorization silently revoked the first one. A user with four companies at one bank therefore signed four times a quarter and ended up with three dead feeds, each still rendering as "Aktiv" with a stale last_synced_at until someone pressed Synka. Prod says this is not one customer: every SEB customer holding connections in more than one company has had an earlier company stop syncing at the moment the next was authorized, most of them while the consent was still formally valid for weeks. The same measurement over other banks is far quieter, so the one-active-session-per-PSU limit is real and ASPSP-side. Enable Banking already supports the shape we want. POST /auth carries no account restriction, so a session covers every account the user ticked at the bank, and GET /accounts/{uid}/transactions takes no session id, so a second company can sync its own accounts from an existing session. bank_connections has no unique constraint on session_id, so this needs no migration. Adds lib/session-sharing.ts plus GET /reusable-sessions and POST /attach. When a live session in another of the user's companies still exposes accounts no company syncs, the settings panel offers to reuse it: the new row shares session_id and consent_expires, carries only the unclaimed accounts, and lands in pending_selection so the existing IBAN-aware account picker does the ledger mapping. Only the consent is shared; accounts, cash_accounts and transactions stay strictly per-company. Sharing a session changes three lifecycle paths, all handled here: - Disconnect and reconnect now refcount before revoking. A blind revoke would take down a sibling company's feed, which is the exact failure this removes. The count runs on a service-role client because RLS hides a sibling in a company the user has since left, and it fails closed: an uncertain count is treated as shared, since a lingering consent lapses on its own in 90 days while a wrongly revoked one kills a working feed. - A renewed consent fans out to every company sharing the old session, and re-points their account uids by IBAN. Several ASPSPs reissue uids on re-authorization, so carrying the session id alone would have left siblings calling retired uids and re-broken them every quarter. This is also why the superseded session_id is no longer nulled at /connect: the callback needs it. - The nightly probe runs once per distinct session and applies the verdict to every row holding it, and expiry mails are keyed per (user, session), so one dead consent is one probe and one mail rather than four of each. Only enabled cash_accounts rows count as claiming an IBAN. The callback mirrors every account in a consent, deselected ones included, so counting any row as a claim would leave nothing offerable once the first company connects. An account handed to a company also stops being offered while that company's picker is still open, closing the window where two companies could book the same physical account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ink2): read the resultaträkning from the pre-closing books INK2R summed journal entries raw, so it included the resultatavslut that zeroes every P&L account into 2099 at year-end. Nettoomsättning, kostnader, periodiseringsfond and skatt all came out as 0, which cascaded into INK2S 7650/7651 and the taxable result. INK2 is always filed after bokslut, so this was every real declaration, and nothing warned: with the P&L at zero the balance sheet still tied out. INK2R now reads two views of the same period. The balance sheet comes from the closed books so 7302 keeps arets resultat via 2099; the income statement comes from the pre-closing books via excludeFinalClosingEntry, which drops only fiscal_periods.closing_entry_id so skatt and bokslutsdispositioner stay on the form (7525, 7528). The equity adjustment is now conditional on a posted closing entry having moved the result into 2099. Second, independent bug: accounts were mapped by BAS number with no regard for the sign of the balance, so konto 1630 with a credit was reported as a negative fordran instead of a skatteskuld and konto 2641 with a debit was netted off the liabilities. The three sign-reclassification rules the K2 iXBRL mapper already had are extracted to lib/reports/sign-reclassification .ts and applied to INK2R too, so both statutory reports present the same balance sheet. Only the rule table is shared: k2-mapper keeps its sumOre arithmetic because the iXBRL path is ore-exact while INK2R truncates per SFL 22:1. NE-bilaga had the same empty-resultatrakning bug and gets the same fix. Adds the closed-period coverage that was missing: the old tests only exercised the mapping table against an open period, the one state in which the engine happened to work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): make the year-end closing decision explicit at every call site generateTrialBalance took two optional booleans, so a caller that never thought about the resultatavslut silently got 'include'. That is the wrong default for anything summing class 3-8: the closing verifikat posts the mirror image of every P&L account into 2099 inside the same period, so the report reads ZERO across the board while the balance sheet still ties out and nothing warns. The booleans are replaced by a required closingEntry: 'include' | 'exclude-final' | 'exclude-all-year-end' with no default, so the build fails until each call site decides. All 40 were audited individually; every one keeps its current behaviour except the two that were provably broken: - Resultatrapport read zero on every line for a closed year, in JSON, PDF and XLSX, and its prior-year comparison column read zero for anyone whose previous year was closed. - Resultat per projekt (dimension-pnl) had the same defect and must stay in lockstep with Resultatrapport to keep reconciling. Both now pass 'exclude-all-year-end', which keeps them agreeing with the formal Resultaträkning rather than pre-empting Stage 2 of #1051 (DECISIONS.md:632). Deliberately unchanged and recorded in DECISIONS.md: the KPI expense composition, which is blank for a closed year but cannot be fixed without a migration and a displayed-figure change, and getBookedBolagsskatt, whose contract is an open period and whose call chain already caused a too-high-tax customer bug once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(vat): keep the resultatavslut out of the momsdeklaration The closing verifikat posts the mirror image of every P&L account into 2099 inside the same fiscal period. Revenue accounts drive rutor 05, 39 and 40, so any VAT period containing the fiscal-year end reported NEGATED turnover once the year was closed. get_vat_declaration_totals already excluded vat_settlement and opening_balance entries, but not this one. Reproduced read-only against production: for December of a closed year the December declaration reported ruta 39 = -794 734 kr. After the fix that period reports 0 and the January period carrying the real sale is unchanged at 794 734 kr. Keyed on fiscal_periods.closing_entry_id, not source_type = 'year_end': avskrivningar, periodiseringsfond and skatt share that source_type and must keep whatever VAT effect they carry. A reversed closing entry is retained together with its storno so the pair still nets to zero, the same predicate trial-balance.ts uses for closingEntry: 'exclude-final'. Migration applied to the staging branch only; prod gets it via merge. The pg test is written but has NOT been executed locally (no DATABASE_URL configured and no local Postgres), so CI is its first real run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(kpi): keep the resultatavslut off the monthly chart The monthly income/expense chart summed every posted entry in the fiscal period. The closing verifikat posts the mirror image of every P&L account, so once a year was closed the fiscal-year-end month charted the whole year's revenue as negative income. Measured read-only on production: 28 companies across 34 month-rows. The worst case charted December income as -10 347 459,81 kr where the real figure is +12,88 kr. Other examples: -1 868 731 -> +128 730, -1 850 501 -> +431 709. Both paths are fixed together so they keep agreeing: the RPC's monthly section now joins the tb_ex_ye_entries CTE it already computes for tb_ex_year_end, and monthly-breakdown.ts (the dimension-filtered fallback and the MCP path) gains the matching source_type filter plus the storno/correction chain of REVERSED year-end entries, so an undone bokslut does not leave half a pair behind. Migration 20260723180000 had recorded the omission as deliberate, on the grounds that it mirrored the JS scan. It did, but the JS scan was wrong. Migration applied to the staging branch (function body identical; three comment lines differ from the committed file). Prod gets the file via merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin every statement generator against a closed fiscal year The per-generator suites all exercised an OPEN fiscal period, which is the one state in which a generator that forgets the resultatavslut happens to work. Declarations are filed AFTER bokslut, so the untested state was the only state that occurs in production. That is why the same defect could ship three times. Two new suites over one shared fixture (closed-year-fixture.ts, a synthetic closed AB with a resultatavslut, a credit 1630 and a debit 2641): closed-year-statements.test.ts enumerates the generators and asserts each reports the year's revenue rather than zero, plus its own bottom line. The table IS the checklist: a new report either appears in it or nothing stops it shipping with this bug. Verified by regressing income-statement back to closingEntry 'include', which fails 2 of its assertions. cross-surface-agreement.test.ts asserts the surfaces agree with each other, which is what every customer complaint actually was. INK2R and the K2 årsredovisning must produce the same årets resultat, the same fritt eget kapital, the same sign reclassifications and the same balance total. The operational family (Resultaträkning, Resultatrapport) must agree internally, and the gap BETWEEN the families is asserted explicitly as bokslutsdispositioner + skatt, so when Stage 2 of #1051 lands the test names the expectation to change instead of failing vaguely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(guards): ratchet against new reports that scan the ledger directly A statement generator that aggregates journal_entry_lines itself has to remember, on its own, that the resultatavslut posts the mirror image of every P&L account into 2099 inside the same fiscal period. Three forgot, and each read ZERO revenue for a closed year while the balance sheet still tied out, so nothing warned. generateTrialBalance now requires an explicit closingEntry mode, which makes that decision a compile error. This guard is what keeps NEW reports on that path: any generator under lib/reports or lib/bokslut that reads journal_entry_lines and is not in the baseline set fails CI. Verified by adding a throwaway report, which the guard rejects by name. Voucher and line listings (general-ledger, journal-register, SIE export, reconciliation, diagnostics) are sanctioned: they show the ledger as posted and have no closingEntry decision to make. Four existing lib/bokslut files are grandfathered rather than migrated. One of them is a genuine open follow-up recorded in DECISIONS.md: sarskild-loneskatt-calculator sums 7410-7419 with no year-end exclusion, so its basis reads ~0 if it runs against an already-closed period. Left alone deliberately: it is a tax figure whose call chain has caused a customer bug before and deserves its own verified change. Also ratchets naive-ore-round down 646 -> 641. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(reports): pin where sign reclassification applies, in both directions No behaviour change. The sweep asked whether the 1630/2641 sign reclassification should be extended to the remaining balance-sheet surfaces; the answer is that there are none left. Both STATUTORY presentations already have it: the K2 iXBRL årsredovisning since 2026-07-23 and INK2R since 2026-07-29. The other two balance-sheet surfaces must NOT have it: /rapporter Balansräkning and Balansrapport are organised by account number under BAS-prefix headings, and balansrapport documents an invariant that depends on every row staying debit-positive where it was booked. Moving konto 1630 into a liability section would break the add-the-rows-to-verify-the-balance property and hide the account from anyone looking it up by number. Asserting both halves is the point. The first half stops the reclassification silently disappearing from one statutory surface again, which is how a customer ended up comparing two of our own reports against each other. The second half stops a future sweep "fixing" the operational reports into disagreeing with their own documented contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(reports): detect statement disagreement instead of waiting for a customer Every year-end problem reported so far was a DISAGREEMENT between two of our own screens, not a single wrong screen. The årsredovisning said one figure, INK2 said another, and the customer did the reconciliation for us. Nothing in the product noticed, because each screen tied out on its own. Two additions: INK2R self-checks. On a closed year it compares the årets resultat it is about to declare against the booked konto 2099, and warns in Swedish when they disagree. This is the alarm that was missing: when INK2R reported 0 kr against a booked 469 542 kr, the balance sheet still balanced, so no warning fired. Mirrors the equivalent check k2-mapper has had since 2026-07-23, so both statutory reports now catch the same fault. reconcileStatements + GET /api/reports/statement-reconciliation return årets resultat from every surface side by side, grouped into families. ledger + statutory must agree and a mismatch is named; operational legitimately differs by bokslutsdispositioner + skatt until Stage 2 of #1051 lands, so that gap is explained rather than flagged. The visual panel is deliberately not built here: it needs a /frontend-design pass against the locked concept conventions plus sv/en strings, and the warning above already puts the alarm where the user looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): address review findings from PR #1293 pg-real (7 failures, one signature): the new fixture called insertFiscalPeriod({ isClosed: true }) and then inserted journal entries into it, so enforce_period_lock (migration 017, legally required) refused the write. Not worked around: the RPC's predicate keys on fiscal_periods.closing_entry_id and never reads is_closed, so the fixture now links the closing entry and leaves the period open, which exercises the path that actually matters. CodeRabbit, closed-year-fixture: EX_YEAR_END_ROWS dropped only the P&L legs of the year_end entries (8811, 8910) and left their balance-sheet legs (2125, 2512) at pre-closing values, so the 'exclude-all-year-end' view sat 160 000 kr out of balance and misrepresented what generateTrialBalance returns. Latent, because today's consumers read class 3-8 only, but a shared fixture that does not balance is a trap for the next consumer. Both legs now go, and a new test asserts all three views sum to zero. CodeRabbit, INK2 totals: renamed totals.resultAfterFinancial to aretsResultat. It holds the result after bokslutsdispositioner AND skatt, which is årets resultat, not resultat efter finansiella poster, and build-data.ts uses the old name correctly for the different subtotal. The UI already labelled the value "Årets resultat", so the name was simply wrong. CodeRabbit, statement-reconciliation: the statutory branch called a generator and caught any throw as "wrong entity type", mapping genuine failures to a null figure that the comparison then skipped, so a real bug in a declaration generator made the function report isReconciled: true. That is the opposite of its purpose. It now dispatches on entity_type and surfaces a generation failure as a named disagreement. CodeRabbit, enable-banking (Emil's call to include): fetchClaimedIbans returned an empty Set on a cash_accounts read failure, which is indistinguishable from "nothing is claimed" and made every IBAN in the session offerable, including accounts another company already books to. Its own comment said it failed closed and its log said "offering nothing"; it failed open. Returns null now, and findReusableSessions offers nothing when the claimed set is unavailable. The test that pinned the fail-open asserted toHaveLength(1) under the name "offers nothing"; it now asserts []. Also removed an em dash per CLAUDE.md. The remaining enable-banking finding (consent-expiry cooldown stamped only on the selected connection, so it leaks one duplicate mail per sibling company) is deliberately left to Emil: it changes email-sending behaviour in his feature rather than fixing a stated contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(reports): resolve second-round review findings on PR #1293 pg-real, two NEW signatures (the closed-period one from cycle 1 is gone): kpi-report-aggregates-rpc.pg.test.ts asserted the exact contract migration 20260730090000 deliberately changes. Its comment read "year_end entries are NOT excluded from monthly" and expected December expenses 1250. That fixture's December holds only year-end-chain entries, so with the fix the month drops out of the chart entirely, which is the correct operational view: a month whose only activity is bokslut has no operating result. Assertion and file docstring updated to the new contract rather than the test being removed. vat-totals-closing-entry.pg.test.ts passed the wrong account arrays. p_net_ accounts is VAT_SETTLEMENT_NET_ACCOUNTS (2650/1650, the momsredovisning settlement pair), not the output-VAT accounts. Putting 2611 there made the extra year_end entry match the settlement-SHAPE detector, so an ordinary sale-with-VAT was classified a momsredovisning and dropped, and the test read 0 instead of 10 000. The RPC was right; the fixture was not. CodeRabbit, statement-reconciliation: resolveEntityType checked neither query's error, so a genuine DB failure (RLS, permissions, connectivity) returned null indistinguishably from "no entity type set", fell into the unsupported-form branch and reported isReconciled: true. That is the same silent-false-reconciled bug the cycle-1 refactor closed, one level down. The companies error now throws; a missing company_settings ROW stays tolerated, because .single() errors on zero rows and many companies have none. Mirrors the pattern the INK2 and NE engines already use. Still open by Emil's explicit choice: the consent-expiry cooldown is stamped only on the connection it was handed, so it leaks one duplicate mail per sibling company on the shared session. That changes email-sending behaviour in his feature rather than fixing a stated contract, so it stays his. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
954ce873a8 |
fix(transactions): confirm and allow undo on a counterparty booking (#1292)
Booking via "Tidigare motparter" ended on a bare setExitingIds().add(id): the row animated out of the inbox and that was the entire feedback. No "Bokförd" toast, no Ångra, no unbooked-count decrement, and the id was never removed from exitingIds again, so an undo would have restored the row's data while leaving it filtered out of the list. Extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch: the counterparty path was already a second, thinner implementation of it, which is why it drifted. Both paths and the counterparty activate-and-retry go through it now, pinned by a parity test. Also fixed on the way: - handleTransactionBooked (manual booking dialog / voucher match) never decremented the unbooked count, so the header read one too high until the next refetch. It deliberately gets no Ångra: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at. - The 350ms animation timer re-applied the booked shape unconditionally, so an Ångra resolving inside that window left the client claiming a journal_entry_id the server had already storno-reversed. A completed undo now wins. - finishBooking cleared processingId unconditionally; scoped to the finished id so it cannot wipe an unrelated row's spinner. No migrations. |
||
|
|
198d3092c7 |
fix: counterparty template pick crashes the page (#1291)
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.
Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).
Five more page-crashes of the same shape, adversarially verified:
- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
a toast description. The Toaster is a sibling of {children} in the ROOT
layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
workspace was in the error boundary for every company that had opened it.
Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.
Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.
No migrations.
|
||
|
|
16f34fb214 |
fix(arcim): resolve OAuth redirect_uri identically in authorize and exchange (#1287)
* fix(arcim): resolve OAuth redirect_uri identically in authorize and exchange The authorize leg honored the FORTNOX_REDIRECT_URI / VISMA_REDIRECT_URI override while the token-exchange leg hardcoded the NEXT_PUBLIC_APP_URL fallback. After the app-domain cutover (2026-07-21) the Fortnox env var still pointed at app.gnubok.se while NEXT_PUBLIC_APP_URL moved to app.accounted.se, so the two redirect_uri values differed and Fortnox rejected every code exchange (RFC 6749 4.1.3). The failure was invisible: the error popup posted its message from the old-domain origin, the wizard's event.origin check dropped it, and the popup closed itself. Both legs now resolve through one resolveArcimCallbackUrl() helper, and the error popup stays open with the reason on screen so a dropped postMessage can never again turn into "nothing happens". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log OAuth popup and redirect-uri rollout decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4501f118c2 |
feat(mcp): approval-queue MCP Apps widget for staged operations (#1278)
* feat(mcp): approval-queue MCP Apps widget for staged operations gnubok_list_pending_operations(render_ui=true) now renders an interactive approval queue (claude.ai / Claude Desktop) where the user approves or rejects each staged operation with a click. High-risk operations arm the approve button and the second click sends confirmed=true, so the BFL 5 kap 5 acknowledgment is a first-party human action instead of the agent asserting confirmed=true on the user's behalf (the audit weakness flagged in dev_docs/erpclaw_analysis.md). - New widget ui://pending-operations/app.html following the established self-contained postMessage/JSON-RPC pattern (no fetch, theme-aware, Swedish labels, expandable preview_data per row). - Result-level _meta.ui hint gated on render_ui=true, mirroring the VAT report wiring; the tool stays data-only by default. - Widget tool references project per namespace (accounted_* clients see accounted_ names inside the HTML). - tools/list payload ceiling 58K -> 58.5K per the in-test convention: prose trimmed to the floor first, remainder is wire contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): time out the widget RPC bridge so a silent host cannot strand a row Review follow-up: sendRequest never settled if the host dropped a response, leaving op._working=true forever with the approve/reject buttons gone. A 30s timeout rejects the promise; the existing catch paths restore the row with an error message so the user can retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3bbf2a051b |
Fix/bank sync bas (#1284)
* fix(year-end): stop revaluing FX items that were not on the balance sheet The year-end close ran currency revaluation as an unconditional step before the irreversible close, and the revaluation queried LIVE open invoices with no date scoping. An invoice issued after balansdagen, settled before it, or never booked at all was therefore revalued into the year being closed, writing down a 1510/2440 that stood at zero. Because the entry lands inside the same run that closes the period, the only remedy left was a rattelse in the following year. The population is now measured as of balansdagen, reusing the reconstruction the reskontra reports already use (fetchPaymentsAsOf / outstandingAsOf): the invoice_date ceiling is unconditional (post-dated invoices make the bug reachable for a current period too) and the widening to 'paid' applies only to a historical date, where a since-settled invoice was still open then. Rows that carry no balance-sheet exposure are skipped per row rather than per company: an unbooked registration is not on 1510/2440. Deliberately NOT keyed on accounting_method, since BFL 5 kap 2 § 3 st requires kontantmetoden companies to book their outstanding fordringar/skulder at balansdagen, and those converted rows are genuine exposure that ARL 4 kap. 13 § must value. The readiness warning stays ungated on purpose: an unbooked FX row is exactly what deserves a warning, because /book still posts it into the year about to close and lockPeriod/closePeriod then removes that remedy for good. The wizard preview now lists the per-invoice revaluation rows it will post instead of three aggregate numbers, so the user approves line-level content before the close. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): reach accounts outside BAS 2026 from a verifikat rattelse A user could not move a verifikat line to konto 8022: the picker reported no such account and offered no way forward. 8022 was dropped from BAS 2026 (it is in BAS 2018), so it is a legitimate company-specific underkonto rather than a catalog gap. Verified against the official bas.se kontoplan that our BAS reference already matches BAS 2026, so 8022 is deliberately NOT added to it: seeding a retired account would push it onto every company. StrikeLinesDialog and CorrectionEntryDialog were the only account pickers in the app that never passed onCreateAccount, so their combobox rendered a dead empty state. Both now open AddAccountDialog prefilled, then refetch the chart and select the new account on the initiating line, leaving the half-finished rattelse intact. AccountCombobox closed its dropdown on the fourth digit of any committed number, which hid the empty state before it was ever painted and made the create affordance unreachable for exactly the numbers that need it. It now closes only when the number matches something, so focus still advances to the belopp field for real accounts. No change to posting rules: correct_entry_lines_inline validates chart membership, not BAS membership, and account creation already required the same write role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(vacation): adjust vacation accrual calculations for mid-year hires and update related logic --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |