6a68ecb4d4b6faafee46a64366ceffbaacda2d02
140
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
50b6299699 |
feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row A ROT/RUT invoice is stored with remaining_amount net of the deduction, so once the customer pays it flips to paid and drops out of the matchable set. Skatteverket's payout for the 1513 share then lands as an income row with no candidate: the only clearing path was a headless settle endpoint that never linked the bank row. The candidate is the payout request (one lump sum per begäran, possibly covering several invoices), modelled exactly like the supplier-invoice hint: - migration 20260904020000: transactions.potential_rot_rut_payout_request_id - pure matcher (exact amount vs decided_total ?? requested_total, boosted when Skatteverket is named, ambiguous when two requests share the amount) - hint written at bank ingest and by batch-match-invoices; cleared by the link and reconciliation paths and by clearSettledInvoiceSuggestions - shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing settle route and the new POST /api/transactions/[id]/match-rot-rut-payout, which books debit 19xx / credit 1513 and links the row in one call - transactions inbox pill, own confirm dialog listing the covered invoices, manual fallback section in the invoice picker, worklist and Att göra rows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers Skeptic findings on 6aa7b2e5c: - a bank row larger than the begäran was booked in full, driving 1513 into a credit balance and rewriting decided_total to the bank amount: refuse amount > decided_total ?? requested_total in the service and block the dialog's confirm with the reason - two concurrent settles could both attach and credit 1513 twice: the request update now locks on settlement_journal_entry_id IS NULL and the loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id - a row with a stale (reversed) journal_entry_id passed the route guard but always lost the null-only link CAS: the route forwards the pointer it read and the service locks on that value, as link-journal-entry does - the pinned underlag on the bank row now propagates onto the voucher Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran CodeRabbit findings on a93dc46b8, one batch: - picker and dialog only offer a begäran to SEK rows (the route refuses other currencies, so the manual flow no longer dead-ends) - a voucher-less `paid` request (beslut recorded via PATCH, money not yet booked) is matchable; settled means a settlement voucher exists - ingest and batch-match check the hint update's error before draining the pool or counting the match - the invoice.match_confirmed payload clears the payout hint like the row - migration 20260904021000: partial unique index on journal_entries (company_id, source_id) for live rot_rut_payout entries, so two racing settles cannot both book a voucher; pg test included Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8265b5d166 |
feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search After a SIE migration or verifikat backfill, customer invoices exist only as journal entries: the invoice list, kundreskontran, /api/invoices, v1 invoices.list, and MCP list_invoices all looked complete while silently omitting everything before the register's first invoice (user report: two invoiced fees nearly re-invoiced as "uninvoiced"). - lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest register invoice; flags posted non-invoice-engine AR verifikat (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so manual/API backfills are caught too. - Invoice list page: one attn line disclosing the boundary (sv+en). - Kundreskontra: register_coverage in the report payload, rendered in the summary card and as an explanation under "Ej avstamd". - /api/invoices GET: invoice_register_coverage in the response. - v1 invoices.list: meta.coverage + registry pitfall documenting it. - MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on the first page, pointing agents at gnubok_query_journal. - Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross amounts with sv-SE formatting, alongside number/customer matching; a known net amount like 14 000 now finds the 17 500 kr row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill Skeptic + CI findings folded into one pass: - Coverage probe: a failed AR lookup now degrades to UNKNOWN (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete". - Probe driven from journal_entries (company-indexed) with the AR line condition as an inner embed, instead of the lines-table-with-embed-filters shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts). - DEBIT-only 1510/1513 lines; excludes every invoice-engine source type (invoice_created, invoice_paid, invoice_cash_payment, credit_note, reminder_fee, rot_rut_payout, storno, correction): an advance payment crediting 1510 or a re-dated rattelse of an engine entry no longer flags. - covers_from ignores drafts so a backdated draft cannot move the boundary. - Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR debits existing IN the reconciled period (new ARReconciliationResult.pre_register_ar_in_period): prior-period migration history cannot explain this period's difference and must not excuse a real felbokning. Wording no longer says "snarare an felbokning". - MCP coverage_note states the earliest register invoice date rather than claiming the register "covers" from it. - Amount search compares magnitudes so credit notes (negative totals) are findable; "-17500" parses; null amounts never match "0". - skills/accounted-api regenerated from the registry (apiskill:check). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * chore(api-skill): regenerate accounted-api skill after merging origin/main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(invoices): round-2 review fixes for register-coverage disclosure - covers_from now anchors on real invoices only (document_type='invoice', non-draft): proformas/delivery notes cannot move the boundary. - INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers (invoice-entries, reminder-fee, rot-rut, storno-service) so a future source_type cannot silently become false pre-register evidence. - Kundreskontra guidance names both 1510 and 1513. - MCP gnubok_list_invoices outputSchema declares invoice_register_coverage and coverage_note. - v1 reports.ar-ledger documents data.register_coverage; invoices.list example made internally consistent; api skill regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF * fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget The expanded schema from the round-2 review pushed tools/list to 61 726 tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The ceiling is policy, not a baseline to bump: the description already tells agents to read invoice_register_coverage/coverage_note, and paginatedSchema has no additionalProperties:false, so the fields stay schema-valid. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> |
||
|
|
9ab823a43c |
fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies (#2260)
* fix(migrations): re-issue the invoice payee migrations skipping migration-reset source companies #2233 merged, but its first migration (20260903150000) failed on prod at the backfill's INSERT into invoice_payee_defaults: ERROR: Archived migration reset source records are immutable (P0001) The insert fires the SECURITY DEFINER mirror into company_settings, and one of the companies with a legacy payment map is a migration-reset source, whose rows are immutable by trigger. The migration rolled back as a whole, prod has neither table nor column, and every migration merged after it is queued behind the failure. Same fix as #2249 used for the country backfill: both entry branches of the backfill now skip companies present in company_migration_resets, and both files are re-issued under fresh versions (20260904010000 and 20260904011000) so Supabase applies them in order after everything that landed today. The failed versions never applied on prod, so no orphan; staging applied them by hand and its schema_migrations rows must be renamed to match (see DECISIONS.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(migrations): make the re-issued payee migrations rerunnable pg-upgrade builds from main, where the first issue (20260903150000) already ran, then applies the re-issued file on top: the composite UNIQUE constraint already existed. Every statement in both files is now guarded (constraint DO blocks, CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY / DROP TRIGGER IF EXISTS before each CREATE), so staging and the preview branches that applied the first issue take the re-issue cleanly too, and prod, which never applied it, is unaffected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d670fe6663 |
feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account Enable Banking has no top-level `bban` key on AccountIdentification: a Swedish BBAN (clearing + account number) arrives as `other.identification` with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed `bban?: string` and read `.bban`, so the value was always undefined: no connected account ever carried its clearing + account number, and domestic counterparty accounts on transactions were dropped. Type the identifiers per the OpenAPI spec, add extractBban() and pickAccountIdentifier(), read counterparty identifiers through the scheme list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on StoredAccount from the OAuth callback. The external_id dedup scope stays IBAN-then-uid and is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): named payee accounts on cash_accounts with a default per currency A company had exactly one set of payment instructions per invoice currency (company_settings.invoice_payment_accounts), picked by currency alone. A second SEK bank account, or a second bankgiro number, had nowhere to live. cash_accounts is already the per-company bank-account entity. Migration 20260903150000 adds the payee fields (bankgiro, plusgiro, clearing + account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a small invoice_payee_defaults table (one default account per currency; one account may be the default for several currencies, a SEK account with an IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites the legacy map and the SEK bank columns from the default accounts. Every existing reader (PDF, email, reminders, v1, MCP) keeps working; the three writers that only touched legacy columns (PUT /api/settings, v1 settings, MCP update_company_settings) now write through to the default account, so what an agent sets is what the PDF prints. Peppol PaymentMeans is built from the resolver instead of the raw legacy column. bg_pg is dropped (never read or written; NULL on every prod and staging row). Backfill lands only on existing cash accounts (primary, IBAN match, or the only enabled account in the currency). Entries with no target stay in the map as the resolver fallback and get an attach action in settings. New: POST /api/cash-accounts (manual bank account on the next free 19xx), PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT /api/cash-accounts/payee-defaults. Settings page rewritten as an account list with per-currency defaults. Behandlingshistorik and the full archive cover the new table and columns. Verified on staging: migration applied (11 defaults landed), mirror trigger observed rewriting company_settings from a payee edit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): choose which bank account an invoice is paid to, frozen at issue Migration 20260903160000 adds invoices.payment_cash_account_id (FK to cash_accounts, SET NULL) and invoices.payment_details, the payee fields frozen when the account is chosen and refreshed at issue. Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount / assertInvoicePaymentAccountForRender take an optional override, and hasRequiredInvoicePaymentAccount reads it from the invoice row, so every surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol, recurring, staged MCP send) prints the frozen payee when one exists and the company default per currency otherwise. Invoices that never chose an account behave exactly as before. Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send, recurring, MCP send and mark-sent) refresh the snapshot from the account as it is at issue; a chosen account that is disabled, un-flagged or unusable for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID. Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice accept payment_cash_account_id and validate it against the company's payee accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the original's payee; copies carry the choice; preview-pdf renders the chosen account. The editor shows "Betalas till" under the currency when the company has two or more usable payee accounts for that currency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * feat(invoices): book manual payments on the invoice's chosen bank account Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the booking dialog's proposed lines debited 1930 regardless of which bank account the invoice asked to be paid to. They now resolve the chosen payee account's ledger account (resolveInvoiceSettlementAccount) and fall back to 1930 only when no account was chosen or the row is gone. Bank-transaction matching keeps debiting the account the money landed on and does not filter by the chosen account; between equal-confidence candidates it prefers the invoice that asked to be paid to the landing account. Scores are untouched, so nothing new auto-matches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(invoices): keep the payload-size and phantom-column ceilings after the payee work Shorten the new gnubok_create_invoice argument description (tools/list payload was 29 bytes over the 60 kB budget), inline the cash-account payee UPDATE/INSERT payloads and the settings select strings as literals so the phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE instead of a hand-rolled copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK) Review findings from CodeRabbit, Superagent, the Swedish accounting review and three skeptic passes, resolved in one batch: Schema (both migrations are unshipped and edited in place): - cash_accounts.payee_iban: the printed IBAN is its own column. iban stays the bank identity written by every sync and used to re-pair on reconnect, so a sync can no longer rewrite an invoice instruction or resurrect a cleared IBAN. The backfill copies each currency entry verbatim onto the target account (IBAN match first, then primary), so every invoice keeps printing exactly what it printed before; the bank IBAN is never pushed onto invoices that did not carry one. - Payee columns are owner/admin-only at the database (BEFORE trigger, service role exempt): cash_accounts is member-writable for bank sync, and the SECURITY DEFINER mirror would otherwise have let a member rewrite where customers pay. - Revoking an account as payee or disabling it drops its defaults; deleting a default drops that currency from the map and clears the legacy SEK columns (an admin saying "nothing to print" must not keep printing a closed account). The mirror leaves the legacy SEK columns alone when the map has no SEK entry, so legacy-only companies are never wiped by a mirror run for another currency. - Audit and mirror triggers fire on the same column set; anon and authenticated can no longer execute the trigger-only definer functions. - invoices.payment_cash_account_id is a composite same-company FK with SET NULL scoped to the account column. Code: - Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now also requires enabled, payee-flagged and usable for the currency), resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which also refuses disabled rows and logs every fallback to 1930). - createManualBankAccount excludes every ledger slot any row already holds (findFreeLedgerAccount treats a manual holder as free; this path inserts). - The legacy settings writers (PUT /api/settings, v1, MCP) write through to the account BEFORE updating company_settings and fail the request on error; the account is written before it is adopted as default so the mirror never sees an empty payee. - snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid projections carry the payee columns; v1 create validates the payee before the dry-run return and echoes it in the preview. - pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and non-account schemes (card PANs) are never persisted. - Editor shows the payee select for a single usable account with no default; the booking dialog waits for cash accounts before proposing lines; a failed default write no longer hides a created account. - Behandlingshistorik names the account on created/deleted defaults. - Regenerated skills/accounted-api; MCP argument description trimmed under the tools/list payload ceiling. Declined: clearing legacy columns via a forward migration (the mirror now does it on delete); Swedish review's "show the debit account in the mark-paid UI" (the booking dialog already proposes and lets the user edit the debit line); manual ledger collision (UNIQUE exists, and the create path now rejects it with a clear error); Peppol aligning to the PDF value for companies whose legacy column had drifted from the map (the PDF is the customer-facing document; both now agree). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves record fields per expression, so the combined condition failed with "record new has no field invoice_payee" whenever a default row changed, which took down every pg-real case on the payee tables. The revoke/disable check now sits inside its own TG_TABLE_NAME branch. The MCP settings executor test mocks the payee write-through like the settings route test already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload Cycle 3 of /resolve-pr on #2233. Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's invoice_payee_defaults rows whenever cash_accounts.enabled flipped to false, and enabled is member-writable (the bank picker's "Synkas ej"), so a member could undo an admin's payee decision. The trigger now drops defaults only on the admin-only invoice_payee true -> false revoke; the mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out of the pick lists and the send gate already refuses an invoice that chose one. Applied to staging as the same function + trigger definition and probed inside a rolled-back block: disable keeps the default and the mirrored bankgiro, revoke clears both. pg-real: the admin-guard test ran three expectations inside one withUserContext transaction; the first raise aborted it and the next statement failed with "current transaction is aborted". One transaction per expectation now, and the member case also flips enabled to prove the column stays member-level. Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa and the 1911-1919 tills. A customer pays to a giro or bank account, so isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH route now require BAS 1920-1999; tests cover 1910 and 1919. Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014 tokens after main merged #2166 and #2163 alongside this branch. The ceiling is not bumped and no read on this surface is a demotion candidate, so gnubok_create_invoice drops payment_cash_account_id; agent-created invoices print the per-currency default and v1 REST plus the editor keep the field. Recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration origin/main merged 20260903160000_kpi_monthly_include_reversed_originals while this branch held the same version; identical versions abort the Supabase apply. Staging's schema_migrations row was moved to the new version with the file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV * fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet Cycle 4 of /resolve-pr on #2233, on Emil's go. Swedish review: the 1920-1999 payee rule lived only in the routes. The cash_accounts_payee_admin_only trigger now also refuses invoice_payee on any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes it, and the backfill only targets giro/bank rows, so a company whose single enabled cash_accounts row is a Stripe clearing account keeps its legacy bankgiro in company_settings instead of landing it on 1686. pg test covers insert and update on 1686 and 1910; the function was applied to staging and probed. Typecheck ratchet: main is red from two merges that landed with failing Checks, and every branch that syncs it inherits the errors. - #2242 added POST(req) calls to the fiscal-periods route test without the route params argument withRouteContext handlers take (25 errors in the file, baseline 23). All 25 calls now pass createMockRouteParams({}). - #2247 made SyncResult.requestedFromDate and historyNarrowed required; the 13 mockedSync results in the enable-banking accounts-route test lacked them. They now carry a fixed date and historyNarrowed: false. Both files' tests pass unchanged in behaviour. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion origin/main merged 20260903183000_party_promotion while this branch held the same version. Staging's schema_migrations row must follow (pending: the Supabase MCP was disconnected at the time of this commit). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e2d38b0ab3 |
fix(invoices): record manual and Stripe settlements in invoice_payments (#2236)
* fix(invoices): record manual and Stripe settlements in invoice_payments (#2019)
settleInvoicePayment created the payment voucher and flipped the invoice to
paid but never wrote the AR sub-ledger row. The kontantmetod bokslut cut-off
reads invoice_payments only (payment DATE, not remaining_amount), so a
manually settled invoice was booked again as a fordran with vilande moms at
year end, double-counting revenue and VAT. The same gap hid the payment from
the Betalningar view and from the voucher -> invoice reference map.
- Insert the row between voucher creation and the CAS status update, same
shape as the bank-match path (amount in invoice currency, transaction_id
null). An insert failure cancels the voucher and fails closed; both CAS
failure branches remove the row together with the voucher.
- Backfill: scripts/backfill-invoice-payment-rows.ts (dry-run default) with
a pure planner in lib/invoices/backfill-invoice-payment-rows.ts. Writes
only where exactly one posted payment voucher exists; zero or several are
reported, never guessed. Rows carry notes 'backfill:#2019' so one DELETE
reverts a run. Executed on staging (10 rows); prod awaits explicit go.
- pg-real: transaction-less rows coexist under the tx/invoice unique index,
the je/invoice index still refuses a double link, and the authenticated
writer can delete its own row (the CAS-failure path depends on it).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): write the payment row from every mark-paid path and harden the backfill
Skeptic and review round on #2236 (issue #2019):
- One helper (lib/invoices/invoice-payment-row.ts) now writes the
invoice_payments row for all four transaction-less settlement paths:
dashboard mark-paid and Stripe via settleInvoicePayment, plus the MCP
mark_invoice_paid commit and the v1 mark-paid route, which booked their
own voucher and never wrote the row. Amount = applied amount (new
paid_amount minus prior), not cash received, so a 3740 öre absorption
never yields a negative fordran in the cut-off or a wrong storno restore.
- The two duplicate detectors no longer treat a payment row with
transaction_id NULL as "reconciled to a bank line": the bank line for a
manual settlement arrives later and the voucher must stay a twin.
- Backfill: payment_date from the voucher entry_date (paid_at was
wall-clock before #1332); refuse rows that disagree with the voucher's
1510 credit / settlement debit; report partially covered invoices
(rows_short) instead of patching; record each executed run in
behandlingshistorik (InvoicePaymentRowBackfilled, migration
20260903180000). Re-run end to end on staging: 10 rows, 10 events.
- Typecheck ratchet: cast in the cut-off test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): use roundOre in the #2019 backfill (guard ratchet)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): log a failed payment-row rollback and keep backfill rows with their audit event
Swedish review round 2 on #2236:
- removeInvoicePaymentRow no longer swallows a failed compensating DELETE:
it logs at error level with company and row id (a stranded row would
read as a settlement in the kontantmetod cut-off) and returns whether
the row is gone. Unit tests for the helper.
- The backfill deletes a company's rows from the run again when its
behandlingshistorik event cannot be written, so rows and change log
(BFNAR 2013:2 p. 9.16) never diverge; the company is listed for a re-run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): keep raw insert errors out of the v1 and MCP mark-paid responses
Compliance swarm on #2236 (ISO 27001 A.8.28): the payment-row insert
failure returned the driver's error text to API callers and MCP users.
The text now stays in the server log; callers get the reason code and a
generic Swedish outcome.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* fix(invoices): never backfill a payment row into a closed or locked period
Swedish review round 3 on #2236: a row dated into a closed or locked
fiscal period changes facts a filed bokslut or deklaration relied on. The
planner now reports such invoices (period_closed) instead of writing them,
and the script header states that the tagged DELETE is an emergency revert
for the window before any cut-off relies on the rows; afterwards the
correction path is a storno of the cut-off verifikat.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pMEgrnPsxDMiYfnXcD2Zo
* test(fiscal-periods): pass route params in the two mid-month tests (typecheck ratchet)
|
||
|
|
3918ff6620 |
fix(customers): make country ISO-2 everywhere and check it against the customer type (#2241)
* fix(customers): make country ISO-2 everywhere and check it against the customer type (#2025, #2028) customers.country and suppliers.country were read as ISO codes by the periodisk sammanstallning (SKV 5740), Peppol and the provider importers but written as English names by the customer form and the v1 API, so a correct German customer produced GERMANY811234567 in the SKV file plus two false warnings, and an EU customer saved with land Sverige got reverse charge with nothing objecting until after the invoice was sent. - lib/vat/country-codes.ts: one helper that normalises codes and the Swedish/English names the writers used to store, the country-vs-type rule (swedish_business = SE, eu_business = EU member other than SE that matches the VAT prefix, non_eu_business = outside the EU), and the reverse-charge country gate. - Writers: customer form and supplier form get a country select; internal REST, v1 REST, bulk-create, MCP create/update, CSV/Excel import and the provider migration mapper normalise to a code and refuse unknown text; the consistency rule is a form error and an API 400 (CUSTOMER_COUNTRY_MISMATCH on update). An omitted country is SE for Swedish types, derived from the VAT prefix for eu_business, required for non_eu_business. - vat-rules.ts: getVatRules and friends take the country as a third argument and grant reverse charge only for an EU country other than SE; every invoice/sales-order/MCP call site passes customer.country. - periodisk sammanstallning reads legacy names through the same helper. - Migration 20260903170000: normalize_country_code() SQL twin, country_raw rollback column on both tables, backfill of every non-code row; unknown text is left as-is. pg-real test for the function. Closes #2025, closes #2028 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE * fix(customers): keep reverse charge for defaulted-SE EU rows, gate the country rule on the fields it reads, fix build Skeptic and CI findings on #2241, one pass: - Migration step 4: eu_business rows whose country was null or only the old writer default (SE) while the VAT number names another EU member take the country from the prefix. The pre-2026-09 rules granted reverse charge on type + VIES validation alone, so these rows invoiced at 0% and would have flipped to 25% on the next invoice. country_raw = '' marks a null origin; rollback uses nullif(country_raw, ''). - countryPermitsReverseCharge refuses SE only: a VIES-validated number outweighs a non-EU address (Swiss company registered in DE, Monaco with a FR number, Northern Ireland XI). - checkCountryConsistency: an eu_business outside the EU VAT area is accepted when the VAT prefix is an EU-trade registration (incl. XI); Monaco maps to the FR prefix. - Internal PATCH, MCP update and the commit executor judge the country rule only when customer_type, country or vat_number is part of the update, so a contradictory legacy row can still change its email (v1 already did). - Webshop-order customers get the order's billing country; spreadsheet import derives a missing country from the type and flags contradictions (parser row error + execute schema refine). - Build: v1 [id] route typed the existing row through a narrowed alias (never) and passed messageSv/messageEn the v1 error context lacks; the self-billed customer projection lacked country. - Checks: regenerated skills/accounted-api (customer example country SE). - New parity test holds the migration's SQL name table to the TS table. - DECISIONS.md: correct migration version and the revised rule. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5EmmndLyDCmY5NHYAvYkE --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
1c82baf553 |
feat(invoices): offert (quote) document type with own OF-series, decisions, conversion, MCP and v1 (#2163)
* fix(invoices): reminders, AR ledger, AR reconciliation and deadlines only read fakturor The overdue-reminder run, the kundreskontra, the 1510 reconciliation and the deadlines page selected invoices by status alone. A sent proforma past its due date was chased with a betalningspaminnelse and flipped to 'overdue', and it appeared as a receivable. All four now filter document_type = 'invoice', which is also the precondition for adding quotes (offert): a quote carries a date but never a receivable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert (quote) document type with its own OF-series, decisions and conversion Adds document_type 'quote' with valid_until, quote_status (open / accepted / declined; expired is derived from valid_until, never stored) and quote_decided_at. Quotes are numbered OF-nnn at insert from company_settings.next_quote_number via generate_quote_number(), the same pattern as delivery notes, so a declined quote never leaves a hole in the F-series the way a proforma does. The column next_quote_number already existed on prod and staging without a migration; the migration adopts it. Engine: build-invoice-write writes the quote columns and keeps remaining_amount at 0; the draft editor refuses accepted or declined quotes; PATCH refuses changing a quote's or delivery note's document type since the number belongs to the series; mark-paid refuses quotes. New POST /api/invoices/[id]/quote-status records the decision and locks once an invoice exists. Conversion is extracted into lib/invoices/convert-to-invoice.ts (one implementation for the route and the MCP staged commit, which had drifted): a converted quote stays and flips to accepted, the invoice links back via converted_from_id and gets its due date from the customer's payment terms; a declined or already invoiced quote is refused. next-number previews the OF-series for quotes. Migration applied to the staging branch and registered as 20260902140000; the pg test runs in CI (pg-real). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): quote PDF, email and filename surfaces The customer-facing surfaces get a quote sibling for every proforma branch: PDF title OFFERT / QUOTE with Offertdatum and Giltig till instead of the due date, a notice that the document is not an invoice or a payment request, and no payment box, OCR, bankgiro, Swish, QR or payment link. The email says the quote is attached and valid until the expiry, drops the payment details and pay-online button, and asks about the quote rather than the invoice. Filenames read "Offert nr OF-001". Seller VAT number and payment accounts are skipped for quotes as for proformas: a quote is not a faktura under ML 17 kap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(invoices): offert in the editor, list and detail pages Editor: "Offert" document type with a required "Giltig till" field (default today + 30 days) in place of the due date; the wire body mirrors it into due_date so the shared schema is satisfied. Payment link, ROT/RUT, periodisering and the bank box are already gated on real invoices. The type cannot be switched on an existing quote (its OF-number belongs to the series). List: an Offerter tab beside Proforma, "Ny offert" in the split button, and a status column that shows the decision or the derived expiry: Utgången and Avböjd are exception chips, Öppen and Accepterad muted text. Detail: Acceptera and Skapa faktura in the header, Avböj in the overflow menu; an expired quote asks before accepting or invoicing (bypassable); once an invoice exists the page links to it as Fakturerad and hides the decision actions. Strings in both sv and en. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * feat(mcp,v1): expose offert on the MCP tools and the v1 REST surface MCP: create_invoice takes document_type quote with a required valid_until and allocates the OF-number at insert; the convert tool keeps its id and accepts quotes with the registry refusal codes; new set_quote_status; list_invoices and get_invoice expose valid_until and the effective quote status, including a derived expired filter. The tools/list payload stays under its ceiling without a ledger change. The MCP staged convert now uses the shared converter. v1: POST /invoices/{id}/quote-status (registered in the endpoint registry, scope map and route loader), valid_until and quote_status in the list, create and detail shapes, and a quote_status list filter. Skill atoms mention offert. Decision log lines for the own number series, derived expiry, accepted-not-cancelled conversion and the header action layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * test(invoices): pass route params and period id in the new quote tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * refactor(invoices): literal update payloads in the converter so the phantom-column guard can read them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W45yD8NfQ97JhyYaXpzN56 * fix(invoices): close the quote review findings in one pass Skeptics (correctness, compliance, regression) and CodeRabbit on #2163: - quote_status is no longer a write-builder output, so a v1 PATCH or MCP update_invoice can never reset a recorded accept/decline; new quotes are opened by the invoices_quote_defaults trigger (20260902141000), which also keeps due_date and valid_until equal. v1 PATCH and the MCP update executor now use the shared editable-draft predicate. - One live invoice per converted source, enforced by a partial unique index; the converter maps 23505 to INVOICE_QUOTE_ALREADY_INVOICED and both quote-status routes compare-and-set on the decision they read. - MCP-created quotes carry remaining_amount 0; mark-paid, transaction match and voucher link refuse non-invoices on the MCP staging tools, the executors and the dashboard link route. - Conversion of a foreign-currency source refetches the rate for the conversion day (ML 8 kap 21-23 paragraphs) and fails closed without one; 0-day payment terms mean due on receipt. - bulk-create refuses quotes per item; list_invoices rejects a quote_status filter combined with another document_type; an omitted document_type on PATCH means unchanged. - attention, push notifications, open-AR count, FX revaluation, year-end and accrual auto-detect and bank-match suggestions only read fakturor. - Quote PDF and email print Summa / Total instead of Att betala. - Regenerated skills/accounted-api for the new v1 endpoint. Declined with reasons in DECISIONS.md: NOT VALID + VALIDATE and CONCURRENTLY on the migrations (repo precedent, 13.8k rows, transactional apply); re-validating VAT treatment at conversion (the converted invoice is a draft the user reviews; follow-up). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): second review round: migration versions, order links, batch allocation, races - Migrations renamed to 20260902220000 / 20260902221000: #2166 shipped its own 20260902141000 to prod while this PR was in review and prod's head moved past both files; below-head versions are skipped by branching, which would have left the quote trigger off prod. Staging rows renamed. - Quote lines never carry sales_order_item_id (an offer must not count as invoiced kundorder quantity); the converter carries a proforma line's order link onto the invoice. - Converter compare-and-sets the source (proforma cancel, quote accept): a concurrent cancel, proforma-to-order conversion or decision removes the orphan invoice with INVOICE_CONVERT_SOURCE_CHANGED instead of a second document for the same sale. - MCP set_quote_status gets the same compare-and-set as the HTTP routes; 0-row updates report INVOICE_QUOTE_CHANGED_CONCURRENTLY everywhere. quote-status (dashboard, v1, MCP) accepts valid_until so an expired sent quote can be reopened, as the docs promised. - MCP mark-paid refuses only quotes, parity with the dashboard route (a sent proforma marked paid is a supported prepayment record). - Batch allocation (dashboard route and MCP tool) refuses non-invoices before the RPC, which gates on status alone. - Customer AR drill-down, v1 customer open invoices and archive guard, and the calendar feed read fakturor only. - Draft quote PDF says "UTKAST" instead of "not a valid invoice"; the editor locks the document type on existing quotes and delivery notes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * chore(invoices): use roundOre in the quote MCP summaries and FX test after main tightened the guard baseline Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): third review round: atomic decision lock, viewer gate, lookup errors, quote payment terms - 20260902222000: BEFORE UPDATE trigger locks an accepted quote while a live converted invoice exists (the compare-and-set in the three decision writers could still be beaten by a conversion landing in between); the routes and the MCP tool map the raise to 409 INVOICE_QUOTE_ALREADY_INVOICED. generate_quote_number now also requires a non-viewer membership so a viewer's session token cannot burn OF-numbers through PostgREST. - Converter checks quote eligibility before the Riksbanken call and treats a failed company_settings read as a failure instead of a 30-day default. - Re-sending the same decision keeps quote_decided_at (idempotent). - gnubok_find_voucher_candidates_for_invoice refuses non-invoices like its write sibling; the dashboard link route surfaces a failed lookup. - Late-fee and credit-term texts never print on a quote. Applied and registered on staging; pg tests added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs * fix(invoices): review nits: fail-closed batch lookup, dry-run expiry, quote heading, quote-date CHECK - match-batch surfaces a failed document lookup instead of allocating. - v1 quote-status dry-run preview carries the new valid_until. - Quote PDF heading reads Offertinformation / Quote information. - 20260902222000 also pins the date invariants the trigger maintains as a CHECK: a quote always has valid_until = due_date, nothing else has one. Applied on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111fYAUxKtpxU1BHiBioqzs --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
3f9a21c64d |
refactor(connect): validate the Peppol connector with the shared contract schemas (#2193)
The hosted Peppol route now parses requests with the schemas published in @accounted/connect-contract instead of its own copies, and the instance transport validates every hosted answer against the contract's response schemas (a shape mismatch is a non-retryable protocol error) and reads the error envelope through connectorErrorSchema. Paths come from the operation table. No behaviour change for a conforming peer; the two sides can no longer drift apart silently. Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f31eeaa603 |
feat(connect): Peppol through the connector (hosted proxy, instance transport, ownership ledger) (#2177)
* feat(connect): peppol connector foundation: capability, ledger/budget service, quota Adds the storage + package shape for brokering Peppol through the connector with the same one-address + rate-budget model as bank/skatteverket: a peppol capability (connector-gated, free on hosted), peppol as a ledger + upstream service, a conservative rate budget, and a migration extending the ledger service CHECK and the per-key limits (peppol_connections_per_company). Proxy route + instance-side Qvalia reroute follow. Switch-on gated on the Qvalia brokering-terms check. (cherry picked from commit 3cc0da6a3, migration renumbered 20260902190000) Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat * feat(connect): Peppol through the connector: hosted proxy, instance transport, ownership ledger Completes the Peppol upstream for self-hosted instances on the connector (WS3): an instance with a connector key carrying the peppol scope and no Qvalia keys of its own sends and receives e-invoices through Arcim's contracted access point, the same way bank and Skatteverket already route. Hosted: app/api/connect/peppol/[...path] speaks the PeppolTransport operations (lookup, submit, status, evidence, recipient PUT/DELETE, inbound list/xml) rather than proxying Qvalia paths, because the Qvalia account is shared by every hosted company and every instance: reads must be scoped to what the caller owns, and the inbound read endpoint is destructive for the whole account. Ownership: a receiving registration is a ledger row (service peppol, participant id in account_uids, sha256 in handle_hash so one key holds a participant at a time); outbound submissions land in the new connector_peppol_submissions table and gate status/evidence; inbound documents are served from the hosted archive filtered by the participants the key holds. Per-company quota (peppol_connections_per_company), the shared PEPPOL_RECEIVING_MAX_REGISTRATIONS cap, and the global peppol rate budget apply. Provider failures cross as CONNECTOR_UPSTREAM_ERROR with the adapter's retryable flag (422 or 502). Instance: lib/invoices/transports/connector.ts implements PeppolTransport over that API and registers itself in connector mode (key present, no QVALIA_* keys); getPeppolTransportAvailability() defaults to it when no provider is selected, so an instance needs no PEPPOL_TRANSPORT_PROVIDER. Webhooks are not brokered; the existing outbound status poll covers it. Hosted is byte-identical: it has its own keys, so connector mode is never on. Docs: SELF-HOSTING.md, SOVEREIGN.md, .env.example. Switch-on for third-party instances stays gated on the Qvalia brokering-terms check; without the scope every operation answers 403. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): authorize Peppol participants per key, harden the proxy after review Review follow-ups on #2177. Authorization: a key may only register (and send as) participant identifiers Arcim recorded on the key at issuance (connector_keys.peppol_participants, migration 20260902191000) or the licensee's own org number, and a document may only be submitted as a sender the key has registered; X-Connector-Company stays an opaque per-company ref. Cap: the shared access-point cap now counts fresh pending reservations and is re-checked after this request's own reservation, so concurrent registrations cannot both pass. Inbound: both halves of the participant id are filtered in the archive query (over-fetched, then exact-pair checked), so foreign rows sharing an identifier cannot consume the limit. Delete: deregistration is a required transport capability, checked before the ledger row is revoked, and registration refuses an access point that cannot deregister. Instance transport: the hosted URL must be https (loopback http only, same rule as getConnectorConfig), and the response body is read inside the timeout window with body-read failures mapped to retryable transport errors. issue-connector-key.ts gains --peppol-participants and --peppol-connections-per-company. Declined: NOT VALID on the ledger CHECK (the table is empty until keys are issued; the validated scan is instant). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): bind Peppol ownership to the instance company, query exact participant pairs Second review round on #2177. Ownership is now (key, company_ref), not key alone: a sender must be registered under the same company header, status and evidence reads look the submission up under the header company, DELETE and re-registration refuse a participant the key holds for another company, so one company on a multi-company instance cannot act on another company's registration through the shared key. The instance transport resolves the owning company from its own peppol_deliveries / peppol_registrations rows before status, evidence and deregistration calls (deps.companyFor, deps.companyForParticipant, wired in transports/index.ts). Inbound listing stays key-wide (the instance routes documents to its own companies by its own registrations). The archive query now runs one exact-pair query per scheme (scheme fixed, that scheme's identifiers), so neither foreign nor cross-pair rows can consume the limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
c0818bb2d2 |
feat(sales-orders): kundorder with partial delivery and partial invoicing (#2166)
* feat(sales-orders): kundorder with partial delivery and partial invoicing Adds sales orders (kundorder) as their own non-ledger document between agreement and invoice, for companies that deliver or invoice in parts. Schema (20260902130000): sales_orders + sales_order_items with RLS via user_company_ids(), OR-<n> numbering RPC (membership-gated, no anon execute), company_settings.sales_orders_enabled UI gate, and back-links invoices.sales_order_id / invoice_items.sales_order_item_id. The invoiced quantity per order line is DERIVED from the linked invoice lines on non-cancelled, non-credited invoices and enforced by a BEFORE trigger, so no counter can drift and a credited invoice frees its quantity. Header status is draft / confirmed / completed / cancelled; completion is kept by DB triggers from the same derived quantity. Delivery and invoicing progress are derived per line, never stored as status. Service + API: lib/sales-orders (create/update with id-preserving line replace, transitions with compare-and-set, cumulative delivery registration, invoice-from-order through buildInvoiceWriteData so booking stays in the engine, proforma -> order conversion), routes under /api/sales-orders and /api/invoices/[id]/convert-to-order, structured SALES_ORDER_* error codes, archive classification of the new tables. The invoice editor round-trips sales_order_item_id so a draft edit cannot drop the link; GET /api/invoices gains ?sales_order_id=. UI: /sales-orders list, create/edit form reusing the invoice line conventions, detail with deliver and create-invoice dialogs and linked invoices; nav row behind the settings toggle; the webshop row is relabelled webshop_orders; "Skapa order" on proformas. MCP (20260902141000/141001): list/get reads plus four staged writes (create, transition, register delivery, create invoice from order) whose executors call the lib services; op types added to the pending operations CHECK. Tests: route tests for every route (401/400/404/happy), service unit tests, executor and tool tests, and tests/pg/sales-orders.pg.test.ts (16 cases, green on staging) covering RLS, numbering guards, the over-invoice trigger incl. release on cancel/credit and cross-company refusal, the quantity floor, and completion maintenance. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQW7mXvbAPgjUHq7dSEamr * fix(sales-orders): harden kundorder after skeptic and security review Resolves every finding from the PR #2166 review pass in one batch. Order link integrity: replaceInvoiceItems now refuses a line set that drops an existing sales_order_item_id (INVOICE_UPDATE_DROPS_ORDER_LINK), closing the MCP update_invoice header-only edit and the v1 PATCH path that severed the link and freed the quantity for double invoicing. The update_invoice re-fetch, gnubok_get_invoice and the v1 item projection now carry sales_order_item_id so well-behaved clients round-trip it. Quantity math: derived remaining/invoiced quantities are rounded to six decimals and compared with an epsilon (roundQty, qtyGreater) so a float remainder such as 0.5999999999999996 can neither refuse the final partial invoice nor land as an invoice quantity; duplicate explicit picks are summed before validation. Leveransdatum: per-line last_delivery_date (migration 20260902160000); an invoice takes the latest date over the lines it covers and only when the covered quantity was delivered, never the header date and never for an advance invoice (ML 17 kap 24 p.7, FX anchor per ML 8 kap 21-23). VAT drift: the order stores the customer type and VAT-validation flag its lines were priced under; invoicing refuses with SALES_ORDER_CUSTOMER_VAT_CHANGED when they differ, and re-saving the order re-validates the lines. Customer and currency are frozen once invoices exist. Tenant and role gates: composite FK (sales_order_id, company_id) ties a line to its parent's company (Superagent P2); aa_enforce_company_writer_role on both tables so a viewer cannot write through the browser client. Proforma -> order refuses proformas with ROT/RUT, periodisering or negative-quantity lines instead of dropping those fields. RESTRICT FK errors on delete map to SALES_ORDER_LINE_LOCKED / SALES_ORDER_HAS_INVOICES. Also: schema-guard literal payloads in lib/sales-orders (ceiling +2 with reason), regenerated skills/accounted-api (sales_order_item_id on invoice items), pg tests for the composite FK, the viewer gate and the new columns, unit tests for every changed path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): resolve CodeRabbit round on PR #2166 Quick wins from the review, all in one pass: - replaceInvoiceItems fails closed when the invoice_items snapshot cannot be read (it is both the restore source and the input to the kundorder link guard); the guard branch is explicit in both PATCH routes. - Cumulative delivery registration carries an optimistic predicate on the quantity it read, so two concurrent registrations cannot regress each other; DELETE of an order keeps its allowed status in the predicate and answers a conflict when zero rows match. - Business dates (order date, delivery date, invoice date) default to the Europe/Stockholm calendar day (todayIsoStockholm), never UTC: the delivery date is also the Riksbanken rate anchor. - The invoice-from-order executor treats an event emit failure as non-blocking: the draft already exists. - sales_order_items are archived through their parent with the order currency denormalised, like invoice_items. - Proforma "Skapa order" tolerates a 2xx without a parsable body; the settings toggle refreshes the server-rendered nav. - List route doc states that q matches the order number (customer names are matched client-side). Declined (out of scope for this PR): moving header + line writes and the delivery loop into transactional RPCs (same PostgREST pattern as the invoice PATCH path, tracked as a follow-up), the MCP approval handler's error message shape (pre-existing code outside this change), and the docstring-coverage warning (no repo convention). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): move hardening migration off a colliding version; archive contract; ceiling - 20260902160000_sales_orders_hardening.sql collided with main's 20260902160000_parties_substrate.sql after the third sync; renamed to 20260902180000 and made idempotent (DROP ... IF EXISTS before each ADD CONSTRAINT) so a preview branch that applied it under the old version replays it cleanly. Staging's schema_migrations row renamed. - sales_order_items goes back to a direct archive dump: the coverage contract (tests/pg/full-archive-coverage.pg.test.ts) requires it for a table with its own company_id; the currency lives on the parent order one file over, joined by sales_order_id. - Scanner ceiling re-baselined after merging main (parties phase 1): 397. - v1 PATCH test queues a real empty invoice_items snapshot now that replaceInvoiceItems fails closed on an unreadable one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW * fix(sales-orders): drop the composite FK before its unique index on replay The idempotent guard in 20260902180000_sales_orders_hardening.sql dropped the unique (id, company_id) before the FK that depends on its index, so the preview branch replay (which had applied the file under its former version) failed with SQLSTATE 2BP01. Order swapped; replay verified on staging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XzFmmH82hCJNmZbPqycDiW --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
f266c386f3 |
chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
18cbc4c30a |
fix(security): audit remediation 2026-09-01: api_keys identity, viewer gates, OAuth binding, XSS, MFA gate (#2155)
* fix(security): bind api_keys to the caller, lock hash-as-bearer RPCs and provider token tables Security audit 2026-09-01, critical items. - api_keys INSERT requires user_id = auth.uid() again (an admin could forge a key for any co-member and act as them in every company they belong to); SELECT is own-keys-or-admin; a BEFORE trigger freezes the identity and credential columns against user-session UPDATEs. - rotate_mcp_refresh_token and validate_and_increment_api_key become service_role only: they match rows by a presented SHA-256, so a hash readable by co-members was a bearer credential. - validate_and_increment_api_key fails closed when the key's user is no longer a member of the key's company. - provider_consent_tokens and provider_otc: the DELETE policies collapsed to "caller has any team row" (correlated subquery on a non-existent team_members.company_id). All member policies dropped; service_role only, matching every existing code path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): role gates, ownership guards and posting integrity in the database Security audit 2026-09-01, high items at the database layer. - One table-level guard, enforce_company_writer_role(), blocks the read-only viewer role on 55 company-scoped tables including through the 15 membership-only SECURITY DEFINER writers. Keyed on the JWT role claim so it fires inside definer bodies; no-op for service_role and trigger cascades. - company_members user_id/company_id immutable from user sessions; invitations can never grant owner; team_members gains a transition guard (admins keep non-owner role moves); companies team_id and archiving are owner-only and team attachment needs team membership. - Direct statements (current_user = authenticated) can no longer insert posted headers, add lines under posted verifikat, or post a draft with a voucher number the sequence never issued. Sanctioned RPCs run as the definer and are untouched; the engine's own draft-then-post shapes still pass. - create_document_version refuses viewers and foreign storage paths; validate_version_chain needs membership and loses anon EXECUTE; match_documents / match_booking_templates lose anon; cron maintenance RPCs become service_role only; the production-only seed_asset_categories is dropped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * build: pin tsx as an exact devDependency instead of fetching it with npx at build time prebuild ran "npx tsx" with no lockfile entry, so every Vercel, Docker and CI build downloaded tsx@latest and its transitive tree from the registry with no integrity check, inside the build environment. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): refuse the viewer role on API-key and MCP write paths The v1 wrapper and the MCP company routing checked company membership but never role, and both run as service role, so a read-only viewer holding an API key could post vouchers and change settings through the API. Mutating methods and non-read scopes now return 403 ROLE_READ_ONLY for viewers on v1; MCP write tools refuse viewers the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): stop serving uploaded SVG, XML and HTML as executable content on the app origin Uploads persisted the browser-declared mime type and the inline proxy served it verbatim, sandboxing only text/html; the storage proxy forwarded the uploader's Content-Type. Any writer, or any Peppol sender, could plant a scripted SVG or XHTML that executed on app.gnubok.se. - inline route: allow-list of natively safe types (PDF, raster images) served as before; everything else gets the opaque sandbox CSP. - storage proxy: octet-stream + attachment + sandbox unless the DB mime for the key is on the allow-list. - document-service: the stored mime is the magic-byte validated type. - logo upload: magic-byte validation, SVG refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): byrå brand logo upload decides the type by magic bytes and drops SVG Same pattern as the company logo route: the logos bucket is public, so a scripted SVG (or anything declared as an image) must never land there. The upload pickers stop advertising SVG. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Enable Banking, Stripe and WooCommerce callbacks to the initiating user The callbacks resolved the pending row by oauth_state alone, so a victim who completed an attacker-initiated consent had their bank account, merchant account or store attached to the attacker's company. requireFlowInitiator() now requires the cookie session of the user who started the flow: no session redirects to login with the callback URL preserved, a different user is refused and nothing is exchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): guard tenant-controlled outbound fetches and surface the disabled rate limiter WooCommerce and Shopify syncs fetched a member-editable store URL with plain fetch() and redirect following under the service role, and the invoice PDF renderer fetched company_settings.logo_url unguarded. All three go through a new safeFetch() (public-IP validation via url-guard, https only, redirect: 'manual', body size cap) and re-normalise the stored host at use time. checkRateLimit() keeps failing open on hosted but logs one error per process when Upstash is not configured and exports isRateLimiterConfigured(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): decide the API MFA gate from server-authenticated factors, not the session cookie getAuthenticatorAssuranceLevel() without arguments derives nextLevel from session.user.factors, which comes from the unsigned sb-*-auth-token cookie. Deleting factors from the cookie made an enrolled account look like it had nothing to step up to, on every /api route and in requireAuth. Both gates now read factors from the getUser() result or listFactors() and the level from the verified JWT claim, and fail closed on errors. Page-branch gate hardened the same way. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind Fortnox/Visma, Gmail and Skatteverket callbacks to the initiating user The arcim-migration callback exchanged the provider code onto whatever consent the one-time state named, with no check of who completed the flow and no org-number comparison, so a phished Fortnox admin handed their ledger to the attacker's company. provider_otc now records the initiating user (migration 20260902100000); the callback requires that session and, after the exchange, refuses a provider company whose org number differs from the consent's company. The Gmail and Skatteverket callbacks enforce the same initiator check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): BankID signup confirms the email before linking the identity Signup created an email-confirmed, MFA-exempt account for any address the caller typed and returned a magic link, so an attacker could pre-register a victim's email and keep a permanent BankID login into the account the victim later adopted. The user is now created unconfirmed, the identity carries email_verified_at NULL (migration 20260902101000), bankid_linked is not set until the mailed confirmation is clicked, and BankID login of a pending identity is refused with the confirmation re-sent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(security): bind MCP OAuth redirect URIs to the consenting user and cap scopes A user-registered redirect URI was allowlisted globally, the consent page named no client, and all scopes were pre-checked, so one phishing link handed an attacker a full-scope key for the victim's company. Registered URIs now resolve only for the registrant or a colleague sharing a company; the consent page shows the client identity and redirect host; non-built-in clients default to read-only pre-checks; scopes are capped by the user's role (viewer: read only) at consent and at /token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(auth): client follow-ups for BankID confirmation, callback mismatch copy and decision log - register client handles the new confirmation_sent response from BankID signup with the existing inbox screen instead of calling verifyOtp. - BankID login surfaces the email_unconfirmed explanation. - WooCommerce settings map woocommerce_error=wrong_user to its own copy. - Logo help text no longer advertises SVG. - DECISIONS.md records the audit remediation choices. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(mcp-oauth): literal SoD columns in the api_keys insert so the phantom-column scanner resolves them Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(logo): type the upload fixtures as Uint8Array<ArrayBuffer> so they are valid BlobParts Fixes the typecheck ratchet on PR #2155 and ratchets the baseline down by the one legacy error the change removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ea45e9dc2f |
fix(invoices): say what payment detail is missing, per currency (#2126) (#2139)
"Fakturan saknar ett betalningskonto för vald valuta" read as a foreign-currency account when the invoice was in SEK and the gap was simply the company's bankgiro; the remediation line also asked for an IBAN, which SEK does not need. A Visma-migrated user marking invoices as sent hit this and went looking for a valutakonto. - describeMissingInvoicePaymentAccount(currency) in lib/invoices/payment-accounts.ts: SEK names bankgiro, plusgiro, Swish or bank account; other currencies ask for an IBAN account in that currency (USD/GBP also offer routing number / sort code + BIC). Both point at Inställningar → Fakturering. - getErrorMessage branches on INVOICE_SEND_PAYMENT_ACCOUNT_MISSING + details.currency (every dashboard route already sends it), before the English registry shortcut so both locales get the specific text. - Registry entry rewritten currency-neutral for consumers without details (API, MCP): bankgiro/plusgiro/Swish/bankkonto for SEK, IBAN otherwise; remediation no longer says IBAN for everything. - Staged-operation commit path uses the helper directly. Tests: helper per currency, client mapping sv/en and the no-details fallback. Closes #2126 Claude-Session: https://claude.ai/code/session_01WFhSQWzu5SyXB6kG5ActZc Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
a08bf51ced |
feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)
* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR 2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record "forandringar i bokforingssystemet som paverkar bokforingsposternas behandling samt nar dessa forandringar infordes", and BFN's commentary names behandlingsregler (automatkonteringar, fasta procentsatser) and new program versions as the examples. Until now both changed without a trace. Audit triggers on the behandlingsregler tables and the import logs: mapping_rules, booking_template_library, categorization_templates, salary_payroll_config, sie_imports, bank_file_imports. categorization_templates learns on every booking (occurrence_count, confidence, last_seen_date), so those telemetry-only updates are excluded by a WHEN clause the same way the api_keys request counters are (20260721115701): only real rule changes are logged. Measured against prod that is roughly 3 800 new audit rows a month against an audit_log already taking 371 688, so about +1 %. app_releases is an append-only log of program versions seen in production, written by the runtime the first time a build answers a request. Vercel exposes no build hook we can trust to write the row, so /api/version records it inside after(): the handler returns synchronously and a floating promise could be frozen before the insert lands, which is how a version log ends up silently empty. The service client is constructed lazily so the constantly polled public probe pays nothing once the module guard is set. Program versions are rolled up per Swedish calendar day in the report. main takes ~570 merges a month, so one event per version would be on the order of 7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the ~400 events a real company's year contains. The statutory unit is the date, and the same sentence qualifies the requirement to changes that affect processing, which a deploy list cannot distinguish anyway. app_releases keeps the per-version truth for anyone who needs to go deeper. AuditLogEntry.user_id becomes string | null. The column is nullable and write_audit_log() falls back to auth.uid(), which is NULL for a service-role or global write; the company-less salary_payroll_config rows are the first that routinely hit it, and the read model already coded for it. Also restores the point citations the 2026-07-27 pass removed while the chapter was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified against BFN's consolidated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): fix two fixture bugs in the behandlingshistorik trigger tests pg-real caught both, and neither is in the migration: the inserts fail before the trigger is reached. mapping_rules.rule_type is constrained to mcc_code / merchant_name / description_pattern / amount_threshold / combined; the test used 'merchant'. booking_template_library's btl_insert policy requires current_user_can_write() and company_id = current_active_company_id(), so the authenticated insert needs a company_members row and a user_preferences.active_company_id, the same setup booking-template-hidden.pg.test.ts uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * test(pg): assert the booking-template audit row inside the user transaction withUserContext always rolls back, so the audit row the trigger writes is gone before an outside connection can see it. The trigger fires in the same transaction as the write, so the assertion belongs there too. The other cases in this file write on the pool (autocommit) and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY * fix(reports): name every build id in the per-day program-version entry Raised by the compliance review on #2097: the roll-up listed five ids and a count, which leaves an auditor unable to reconstruct which versions ran that day. app_releases keeps the full record, but the report is the surface anyone actually reads. A day is bounded by the deploy rate (~19), so the full list stays one readable cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0406e628e1 |
fix(settings): scope cross-field VAT validations to saves that touch them (#2121)
* fix(settings): scope cross-field VAT validations to saves that touch them
The settings PUT validated the whole effective record on every partial
update, so companies stored as vat_registered without a vat_number were
blocked from saving anything through the endpoint, including the invoice
bank-details dialog, which has no VAT fields (reported by a user stuck on
"Momsregistreringsnummer kravs...").
Each cross-field check (VAT completeness, 40m-monthly, periodisk
sammanstallning) now runs only when the request body touches a field in
its group, so the invariant still holds whenever VAT config is edited.
Explicit null now counts as clearing a value during validation instead of
falling back to the stored one, closing a latent hole where
{ vat_number: null } passed validation but wrote null.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): gate issuance on the seller VAT number (skeptic finding)
The settings scoping in the previous commit removed what was accidentally
the only enforcement of "momsregistrerad implies momsregnr on file": with
bank details saveable again, a registered company without a stored VAT
number could issue a faktura charging moms with no seller VAT number in
the footer (mandatory element, ML (2023:200) 17 kap. 24 §).
Issuance is now gated the same way the payment account is, at all four
independent issuance points (issueAndBookInvoice, dashboard send, v1 send,
v1 mark-sent), with a structured error pointing at Installningar -> Skatt.
Credit notes, proformas, and delivery notes are exempt like the payment
gate exempts them.
Also, per the Swedish review and the secondary skeptic finding:
- PS/EU-trade edits join the VAT-completeness touch group, so enabling
periodisk sammanstallning on an incomplete registration keeps failing.
- The stale ML 11 kap. 8 citation is updated to ML 17 kap. 24.
The makeCompanySettings fixture now models a coherent registered company
(vat_number set); the missing-number tests override it explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* fix(invoices): extend the seller-VAT-number gate to the headless issuance paths
Skeptic round 2 found three more issuance points beside the four gated in
the previous commit: the recurring auto-send service (cron, no human in
the loop), and the MCP staged-operation executors send_invoice and
mark_invoice_sent. Each carried the payment-account gate but not the VAT
gate; mark_invoice_sent additionally had a narrow settings select that
would have made a naive gate silently pass, now widened.
Recurring auto-send fails soft, matching its other guards: the invoice
stays a numbered draft with the standard schedule warning. The executors
return the structured Swedish message. Peppol send was verified
self-gating (BIS preflight requires the supplier VAT number).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
* test(email): refresh brand-mail snapshots for the coherent VAT fixture
The makeCompanySettings fixture now carries a VAT number, so the invoice
and reminder mail footers correctly render the VAT line; the snapshots
predate that. Also cites ML 17 kap. 22-23 (andringsfaktura content list)
in the seller-vat-number docstring per the Swedish review suggestion,
documenting why credit notes are exempt. No behavior change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fjJLUucErb1ZHyQ57fe1u
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
f216a60bf8 |
feat(invoices): per-line percentage discount and separate fakturamarkning (#2084)
* feat(invoices): per-line percentage discount and separate fakturamarkning User request: rabatt i procent per artikelrad, and a marking field separate from Er referens. - invoice_items.discount_percent (0-100, default 0): line_total and vat_amount are stored NET of the discount. Shared exact-ore math in lib/invoices/line-amounts.ts (gross, discount, net) used by the web builder, staged-operation commit, editor preview, PDF, and Peppol. Undiscounted lines keep the legacy unrounded qty*price byte-identical. - ROT/RUT deduction computes on the discounted net line total. - invoices.invoice_marking: printed on the PDF next to the references and mapped to Peppol BT-10 BuyerReference (marking wins over your_reference; either satisfies the BT-10 requirement). - Peppol renders the discount as a BG-27 line AllowanceCharge (reason code 95, MultiplierFactorNumeric, Amount, BaseAmount). - Editor: "Lagg till rabatt" in the row menu (same reveal pattern as ROT/RUT), Markning row next to Er referens, forval chip, review dialog shows discounts and marking. - Plumbed through v1 REST projections, MCP create/get/update invoice tools, pending-operations update path, and copy-invoice (discount copied; marking deliberately not, it is recipient-specific). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 * fix(invoices): carry discount_percent through every deduction, credit, convert and preview path Skeptic + CI findings on the discount/marking feature, one pass: - generateRotRutLines and propose-send-lines now pass discount_percent into computeDeduction: the send/credit/cash verifikat booked 1513 on the GROSS line while deduction_total, the PDF and the Skatteverket claim carried the net, stranding the difference on 1513 and pushing 1510 negative once the customer paid. Test pins 1513=3000/1510=7000 for a 20%-discounted 10 000 kr ROT line. - preview-pdf route accepts discount_percent (net totals + net-based deduction) and invoice_marking; the editor now sends the marking, so the preview equals the invoice it becomes. - Credit notes carry discount_percent (buildCreditNoteItem, v1 credit route select+insert, MCP credit executor) and invoice_marking, so the kreditfaktura face arithmetic multiplies out and shows the Rabatt column (ML 17 kap 24 §). - Proforma->invoice convert copies discount_percent + invoice_marking: the converted invoice previously failed Peppol LINE_TOTAL_MISMATCH and lost the rebate on the next builder pass. - Editor hides the discount menu in self-billed mode (the self-billed wire shape has no discount; previewed net would book gross). - MCP staging and commitCreateInvoice reject a non-number discount_percent (a string coerced past the range check but was ignored by the totals math and still stored). - Regenerated skills/accounted-api (apiskill:check CI failure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJAt9yM7tgZ69f1XnNnq52 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e92b5a59b2 |
fix(reminders): settings UI discloses that automatic sending is disabled (#2033)
* fix(reminders): settings UI discloses that automatic sending is disabled The invoice reminder cron has answered 503 since May 2026 (PR #583), so no automatic reminders are sent, but the settings UI still let users configure reminder day levels as if sending worked. Introduce REMINDERS_SENDING_ENABLED (lib/invoices/reminders-enabled.ts) as the single shared flag read by both sides: the cron route uses it as its 503 gate (with the original sending pipeline restored behind it, so re-enabling later is one flag flip), and the invoice settings form shows an attn notice while the flag is off. Schedule settings stay editable; notice strings added to both sv and en locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 * docs(reminders): correct the re-enable contract after skeptic review The flag docblock, route docblock, and test header claimed flipping REMINDERS_SENDING_ENABLED alone resumes sending. False on hosted: the route has had no vercel.json cron entry since PR #559 and the crontab ratchet pins it in INTENTIONALLY_UNSCHEDULED, and POST requires the cron secret so no dashboard can trigger it. Rewrite the claims into the real re-enable checklist and record the pre-flip prerequisites surfaced by review: invoice_reminders lacks a unique (invoice_id, reminder_level) constraint and the fee entry is booked before the reminder row, so a run dying mid-batch double-books the fee; the backlog would get highest-level reminders first. Comments and a test name only; no runtime change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9f8fa1b692 |
feat(invoices): draft invoice delete on v1 and MCP with staged approval (#2036)
* feat(invoices): draft invoice delete on v1 and MCP with staged approval
Draft customer-invoice deletion was web-only. This makes the same
semantics available on the v1 API-key surface and as an MCP write tool:
unnumbered drafts are hard deleted (no F-series number was consumed, so
no gap arises), numbered drafts are makulerade (status 'cancelled',
number retained so the F-series stays gap-free per ML 17 kap 24 and
BFNAR 2013:2). Non-drafts are refused; posted invoices can only be
reversed via a credit note.
- extract the web DELETE logic into lib/invoices/delete-draft-invoice.ts
with an explicit userId param (service-role clients null auth.uid());
the cookie route behavior is unchanged
- add DELETE /api/v1/companies/{companyId}/invoices/{id}: 409
INVOICE_DELETE_NOT_DRAFT for non-drafts (status override; the cookie
route keeps its 400), 404 generic NOT_FOUND, dry-run preview of the
outcome, mandatory Idempotency-Key; scope invoices:write
- fix the stale v1 PATCH pitfall that claimed a DELETE handler existed
- new MCP tool gnubok_delete_draft_invoice: staged operation requiring
approval, risk 'high' (both outcomes irreversible, never
auto-committed), catalogVisibility 'search' (tools/list budget at zero
headroom)
- delete_draft_invoice commit executor delegating to the shared service,
plus pending_operations CHECK constraint migration pair
(20260830100000/100001), risk tier, scope map, Granskning vocabulary
and sv/en labels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after 20260830101500 on main
Merging origin/main brought 20260830101500_seed_agent_atom_bodies; the
constraint pair must sort after every version already on main so it
never applies out of order at merge time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* docs(api-skill): regenerate accounted-api skill for the new invoices.delete endpoint
apiskill:check failed on CI: registering DELETE /invoices/{id} makes the
generated skills/accounted-api docs stale. Output of npm run
apiskill:generate, no hand edits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(invoices): pin staged delete outcome and align v1 risk metadata
Skeptic findings on PR #2036:
- Outcome pin: gnubok_delete_draft_invoice stages
expected_invoice_number alongside invoice_id; the executor passes it to
deleteDraftInvoice, which refuses with INVOICE_CANCEL_RACE when the
draft's number changed since staging. An unnumbered draft finalized
between staging and approval is now auto-rejected with a message naming
the new number, instead of silently switching from the approved hard
delete to a makulering. Ops staged without the pin keep legacy
semantics; single-phase callers (web, v1) are unaffected.
- v1 invoices.delete registerEndpoint risk raised medium -> high to match
the delete_draft_invoice pending-op tier (both outcomes irreversible);
generated accounted-api docs regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
* fix(migrations): renumber delete_draft_invoice pair after skattekonto collision
Merging origin/main brought PR #2039's 20260830130000/130001 pair, which
collides with this branch's versions AND re-creates the same
pending_operations CHECK wholesale. Renumber to 20260830150000/150001 and
rebuild the value list as a strict superset (skattekonto list plus
delete_draft_invoice) so applying last revokes nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtvffGr6uVk2J2Skuz6L98
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7162053e89 |
fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual (#2037)
* fix(bookkeeping): link RPCs settle cross-currency invoices with 7960/3960 FX residual A foreign-currency invoice whose receivable (1510) or payable (2440) was booked in plain SEK could not be settled by any API path: link_invoice_to_voucher and link_supplier_invoice_to_voucher failed closed with LINK_VOUCHER_CURRENCY_MISMATCH on every SEK-booked matched-side line. Port match_batch_allocate's cross-currency settlement into both RPCs, with identical sign conventions: when every matched-side line is genuinely SEK-booked, the invoice has a sane exchange_rate, and the voucher's SEK sum is within 10 percent of remaining * rate, the voucher settles the full remaining and the FX residual (booked_sek - settled_sek) is booked to 7960 (loss) / 3960 (gain). Because the linked voucher is posted and immutable, the residual lives in its own balanced two-line verifikat committed through commit_journal_entry, dated on the voucher's entry_date with an explicit open-period check. Every ambiguous case (mixed readable/SEK lines, third currency label, missing rate, kontantmetoden, deviation outside the band, locked period) keeps the existing mismatch codes, now with details.reason. Verified with a 14-scenario transactional probe against staging Postgres (rolled back; catalog untouched) plus tests/pg/link-voucher-fx-residual .pg.test.ts, which applies the migration inside each test's transaction so it runs against a database that has not applied it yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): move FX residual migration past 20260830101500 from main Main gained 20260830101500_seed_agent_atom_bodies.sql, a later version than this branch's 20260830100000; renamed to 20260830120000 so the migration chain stays ordered. Test and decision-log references updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): gate the FX fallback on readable line count, not sum Skeptic counterexample: a matched-side line labelled with the invoice's currency but carrying amount_in_currency = 0 is a readable LINE that sums to 0. The sum-based gate engaged the fallback while the line's real SEK ledger movement was excluded from the settled sum, over-crediting the receivable (mirrored on AP) and booking a phantom FX result. The gate now counts readable lines: any readable line disables the fallback, so the SEK sum is provably the full matched-side ledger amount whenever it engages. Verified against staging Postgres in a rolled-back transaction: both counterexample vouchers now refuse with LINK_*_CURRENCY_MISMATCH and no writes, while the plain-SEK settlement paths still book balanced 7960/3960 residuals. Regression tests added for both sides. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(bookkeeping): move FX residual migration past colliding 20260830120000 from main Main gained 20260830120000_reminder_text_overrides.sql, colliding with this branch's version timestamp; renamed to 20260830140000 (references updated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy * fix(invoices): stage-time validators mirror the RPC's SEK settlement gate The MCP staging path pre-validates links with validateVoucherForInvoiceLink and validateVoucherForSupplierInvoiceLink before the RPC ever runs, so the new FX residual fallback was unreachable through MCP: the exact case this change exists for. Both validators now mirror the RPC's gate byte-for-byte (migration 20260830140000): accrual only on the customer side, zero readable lines counted per LINE (a zero-amount readable line disables the fallback), every unreadable matched-side line SEK-booked, sane exchange_rate bounds, and the 10 percent deviation band; eligible vouchers validate as a full-remaining settlement, everything else keeps the CURRENCY_MISMATCH refusal with details.reason. Unit tests: fallback settlement, deviation refusal, zero-amount readable line refusal, kontantmetoden refusal, missing-rate refusal, and supplier mirrors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e313bfa8ec |
fix(invoices): ROT/RUT kontantmetoden invoices could not be marked paid (#2040)
* fix(invoices): derive mark-paid amount as customer settlement, not gross debit sum remaining_amount on a ROT/RUT invoice is stored net of the deduction (total - deduction_total): the customer owes only their share, and Skatteverket's share sits on 1513 until the payout flow clears it. The kontantmetoden payment entry correctly books two debit legs (bank = customer share, 1513 = deduction), but both mark-paid routes summed ALL debit lines as the payment amount, so the gross total was compared against the net remaining and every ROT/RUT cash invoice was rejected with MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total, stalling the whole ROT chain (unpaid invoice never becomes a payout candidate). New deriveCustomerSettlementAmount in lib/invoices/apply-invoice-payment excludes the net 1513 debit, capped at the invoice's own deduction (so invoices without a deduction keep byte-identical behavior, including rejecting a hand-added 1513 overshoot), and both the dashboard and v1 mark-paid routes use it. The verifikat still books the full entry including the 1513 leg; only the settlement math changes. Reported by a user unable to mark ROT invoice 1123 as paid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ * fix(invoices): harden ROT settlement derivation per skeptic refutations Three fixes from adversarial review of the previous commit: 1. v1 mark-paid never fetched deduction_total (the pre-flight select projects explicit columns), so the exclusion cap was always 0 and the v1 API still failed with MATCH_AMOUNT_EXCEEDS_REMAINING. Fetch it ad hoc next to journal_entry_id (kept out of the response contract) and assert the projection in the route test, since the mock harness ignores select strings. 2. Gate the 1513 exclusion on the invoice NOT being booked yet, in both routes. An invoice booked at send already debited 1513 in its registration entry; ungated, a cash-shaped payment entry on such an invoice would post (orphaned 1510, doubled 1513, double revenue and VAT) where the gross guard used to reject it. 3. payment-sync's reversal recompute now stores remaining_amount net of deduction_total, matching build-invoice-write and the DB guard. Recomputing gross made a storno'd ROT cash invoice permanently un-payable under the net settlement derivation (net payment can never reach a gross remaining; the cash-partial block rejects the rest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
521f437072 |
feat(migration): link migrated invoices to their registration voucher (#1463) (#2024)
Visma and Fortnox migrations now carry each invoice's source voucher reference, and after the invoice steps a core linker resolves it against the SIE-imported ledger (voucher-ref resolver by date, corroborated by the 244x credit / 151x debit amount, posted only, unreferenced only) and writes registration_journal_entry_id / journal_entry_id. Anything ambiguous, mismatched or unresolved is reported and left NULL; journal entries are never written. The arcim-migration /reconcile endpoint can relink already-migrated companies. Payment vouchers are PR B. Refs #1463 |
||
|
|
fdb5f6f891 |
feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c93a97bb4e |
fix(invoices): force 0% VAT on recurring and bulk-created invoices when the company is not VAT registered (#1838)
Issue #1719: moms lands on an invoice even though momskrysset (company_settings.vat_registered) is off. The web and v1 create/update routes, the MCP commit, and the webshop route all zero every line via buildInvoiceWriteData, but two paths insert invoices directly and never consult vat_registered: 1. executeRecurringSchedule (cron + run-now): the schedule dialog defaults template lines to 25%, stores vat_rate with no gate, and the spawn falls back to the customer default (25% for Swedish customers) for null-rate lines. The generated invoice carried 25% output VAT and could be auto-emailed to the customer and booked against 2611. 2. POST /api/v1/.../invoices/bulk-create: same fallback, same direct insert. Both now mirror buildInvoiceWriteData: when vat_registered is false, every line is forced to 0% at spawn/create time, and the header lands as treatment 'exempt' with moms_ruta and reverse_charge_text null. Self-billed received invoices deliberately keep their stated VAT: the counterparty issued that document, and the books must mirror it (ML 16 kap 23 §). Credit notes keep mirroring the invoice they credit. Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
638a25a11a |
fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap (#1910)
* fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap The payout file is whole kronor and skattereduktionen is capped at a share of the work price (HUSFL: 50% RUT / 30% ROT). Math.round pushed an exact half-krona deduction up: 125 kr work -> 62,50 kr RUT became begart 63 with betalt 125 - 63 = 62, so the DEDUCTION_EXCEEDS_PAYMENT guard blocked a perfectly correct invoice. Every work price that is an odd number of kronor hits this. BegartBelopp now floors the ore-rounded sum: 62,50 -> 62, betalt 63, valid file. Flooring can never create begart > betalt (2*floor(D) is an integer <= pris <= round(pris) whenever the ledger deduction respects the cap), so the guard becomes a pure corruption check. Invoices that pass today are unchanged: round and floor only differ at fraction >= .5, and those were all blocked. The ore-rounding before the floor keeps float noise (62.499999...) from dropping a whole krona; a test pins it. Regression tests cover the 125-kr half-krona case end to end (evaluate, XML amounts, eligible list). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(rot-rut): use roundOre helper for the pre-floor ore rounding check:guards naive-ore-round flags inline Math.round(x*100)/100; the sanctioned @/lib/money roundOre does the same with an EPSILON nudge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): pin the ROT half-ore class and use truncateToWholeKronor Skeptic findings on the frozen head (fbc3bb0c8): 1. The 'previously-passing invoices are byte-identical' claim was false for ROT: at 30% the deduction sits far below the begart > betalt guard, so e.g. 500 kr labor + 125 moms (ROT 187,50) previously PASSED and emitted 188, exceeding both the statutory cap and the 1513 fordran. The floor now emits 187/438: deliberately 1 kr lower. A test pins the case, and DECISIONS.md states the real blast radius. 2. lib/money.ts already ships truncateToWholeKronor, documented as the amount rule for everything Skatteverket-bound, with the same ore-round-then-truncate semantics; use it instead of hand-rolling Math.floor(roundOre(...)). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cbfb2201ff |
fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)
* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) Four silent drop paths made a paid RUT invoice invisible in the begaran dialog (neither eligible nor blocked), and the empty list hid the year picker so the dialog looked dead: 1. deduction lines without a header deduction_total: a second line-based candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING (also at file generation: the 1513 receivable was never booked). 2. partially_paid with the customer share settled: remaining_amount = 0 (total - paid_amount - deduction_total, migration 20260817191708) now counts as paid in evaluateInvoiceForFile; a genuine partial blocks as NOT_PAID with the outstanding amount. 3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message points at the other type, and the dialog's empty state adds a switch-type hint. 4. invoices held by a generated/submitted begaran block as ALREADY_REQUESTED naming the request; decided requests stay omitted (finished business, visible in the history list). The dialog keeps the year picker rendered when the list is empty (current year as inert fallback) and opens the blocked list by default when nothing is eligible. Fixes #1884 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884) Two skeptic refutations against the frozen PR head: 1. Regression: the wrong-type branch ran before the active-request lookup, so invoices of the OTHER type whose begaran was already decided resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked list, and the empty-state hint pointed at a tab where they never appear. The decided-request skip now runs first, on every tab. 2. Correctness: the paid gate and the NOT_PAID message trusted remaining_amount, but payment-sync's storno path recomputes it WITHOUT subtracting deduction_total, so the stored column can carry Skatteverkets 1513 share and the dialog could assert a wrong customer-outstanding figure. The gate now derives the customer share as total - paid_amount - deduction_total (the buildInvoiceWriteData / migration 20260817191708 formula) from fields every settlement path maintains. Tests pin both: decided+wrong-type omitted from both lists, corrupted remaining still classified and reported from the derived share. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884) The skeptic-hardening commit moved the paid gate off remaining_amount to the derived customer share (total - paid_amount - deduction_total); the comment still named remaining_amount as the signal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): explicit decided-status set + correction-path wording (#1884) Swedish accounting review findings on the candidate list: 1. The decided-begaran skip inferred 'decided' by exclusion (anything not generated/submitted), so a future request status would make an invoice vanish from both lists, exactly the silent drop the module forbids. DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other status held by a request lands in blocked as ALREADY_REQUESTED with a generic message. Test pins it. 2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which could read as an invitation to edit a booked invoice directly. The invoice edit route already refuses sent/paid/booked invoices, and the message now names the sanctioned path: drafts edit directly, sent or paid invoices are corrected via credit note + new invoice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
78525bd391 |
fix(invoices): make self-billed invoices creditable and their dates visible (#1827)
A self-billed invoice has invoice_number null by design (the counterparty's number lives in external_invoice_number), which broke the whole credit flow: the confirm input was disabled and compared against null, the API minted the literal number 'KR-null', and the credit-note PDF dropped its ML 17 kap 22 reference to the original. The editor also hid fakturadatum inside the collapsed Forval panel, so self-billed invoices silently registered with today's date and, being immutable, could not be corrected. - creditConfirmNumber() falls back to external_invoice_number; the credit page uses it for reason default, subtitle, original row, preview, confirm label/placeholder/disabled, mismatch check and submit gate - createCreditNote numbers 'KR-<external>' for self-billed originals and refuses with typed 400 INVOICE_CREDIT_NO_NUMBER when no number exists - mark-sent and send select external_invoice_number and fall back for the credit-note PDF's reference to the original - the Forval chip line now shows the invoice date in every mode, and self-billed mode renders fakturadatum + mottagningsdatum uncollapsed as transcription fields next to the external number Fixes #1820 Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1f6dd778e5 |
fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN (#1806)
* fix(invoices): reminder emails use the per-currency payment account, never the SEK IBAN Invoice PDF and invoice email resolve payment details by invoice currency (invoice_payment_accounts, #1116), but the overdue-reminder templates still read the legacy company_settings fields, so a EUR reminder printed the SEK account's IBAN. A customer has paid to the wrong account from one. Both reminder generators now go through companyWithInvoicePaymentAccount, and sendReminder applies the same gate as invoice send: no usable account for the invoice currency means the reminder is skipped with INVOICE_PAYMENT_ACCOUNT_MISSING:<currency> in the result, instead of going out with no (or the wrong) payment details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): run the reminder payment-account gate before the fee entry and reminder row Skeptic refutation (3/3): the gate lived in sendReminder, after processOverdueReminders had already posted the 60 kr påminnelseavgift verifikat and inserted the invoice_reminders row, so a skipped EUR reminder booked a fee for an email never sent and burned the level for good. The gate now runs in the loop before any write; sendReminder keeps it only as a backstop for direct callers. New processor test asserts no fee entry, no row and no email for the skipped case, and all three once an account exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0040cadacc |
feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain Companies holding the custom_sender_domain capability grant can register their own domain (Resend sending-only profile), publish DKIM/SPF, and once verified every invoice email (send, reminders, recurring, payment confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>" instead of the platform sender. Reply-To is unchanged. - New table company_sending_domains (RLS: members read, owner/admin write; audit trigger), types, archive-export classification. - New capability key custom_sender_domain: manually granted per company, deliberately outside PAID_CAPABILITIES (never trial-seeded, never written by the Stripe sync). Without the grant the settings section is hidden and nothing changes. - Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify), Resend domain lifecycle without orphan adoption, domain.updated handling on the delivery webhook, explicit From support in the Resend adapter. - Core resolveInvoiceSender(): verified + enabled + entitled, else the platform sender; never throws. - Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en). - Unit tests for the resolver, domain helpers, routes, From header; pg-real test for RLS and constraints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): harden sending-domain writes, sender fallback, review findings Skeptic refutations: - Tenant JWTs could insert/update company_sending_domains with status = 'verified' and an arbitrary domain through PostgREST (RLS only checked membership), then send invoice mail as that domain. New migration 20260822130000 adds a BEFORE trigger: tenants may only open a pending claim and edit sender_local_part/sender_name/enabled; domain and verification state are service-role only. claim/verify helpers now take a service-role writer for those columns; the route's RLS client still does the insert. - A company domain Resend later rejects made every invoice send fail: the Resend adapter retries once as the platform sender when an explicit company From is rejected (nothing was sent, so no double send). Review findings: - domain.updated webhook: discriminated outcome; DB errors answer 500 so Svix retries, unknown domains are acknowledged. - Display names are RFC 5322-quoted only when they carry specials. - Sender local part is a strict dot-atom (no trailing/consecutive dots), in code and in the CHECK constraint; resend_domain_id index is UNIQUE. - IME composition guard on the claim input; event bus reset in tests; settings section skips its request for non-admins. Deferred (needs a product call): persisting the effective From address in the invoice delivery log touches the hardened evidence triggers; recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a tenant could delete and re-insert its pending row under the same id with a reserved domain, and the service-role writer updated by id alone. Now: - the claim's verification-state write filters on (id, company_id, domain, resend_domain_id IS NULL) and rolls back on zero rows; - verify and the domain.updated webhook compare Resend's domain name with the row before writing verified; - resolveInvoiceSender refuses reserved platform domains and non-hostnames at send time (reserved-domain logic moved to lib/email/domain-name.ts and shared with the claim validator). pg-real: the case-insensitive uniqueness assertion now expects the domain_shape CHECK (lowercase enforced) for an uppercase variant and the unique index for a same-case duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3ac80edc96 |
feat(peppol): gate Peppol per company: request access, operator enables with a sending cap (#1794)
* feat(peppol): gate Peppol per company: request access, operator enables with a sending cap Peppol is no longer available to every company by default. Each transmission is billed per document by the access point and each receiving identifier consumes a contracted tenant slot, so the product now works like this: - peppol_access (new table, RLS read-only for members, service-role writes): status requested | enabled | disabled, max_sends (null = no cap), receive_enabled as a separate grant, who asked and who enabled. - POST /api/settings/peppol/access: the company asks from Settings > Fakturering; the row is written and the operators are e-mailed (best effort, the row is the source of truth). - scripts/peppol/access.ts list | enable <company|orgnr> [--max-sends N] [--receive] | disable | show: the operator side. - POST /api/invoices/[id]/peppol/send refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_SEND_LIMIT_REACHED before touching the invoice; the invoice page's send item says so instead of pretending. Registration for receiving refuses PEPPOL_ACCESS_REQUIRED / PEPPOL_RECEIVING_NOT_ENABLED. - Settings UI: access status row with "Begär åtkomst", sends used of cap, receiving switch only once receiving is granted. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): pass route params to the settings handlers; baseline-align the access row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): revoke default table privileges from authenticated on the access and receiving tables Supabase grants ALL on new tables to authenticated by default; the earlier REVOKE covered PUBLIC and anon only, so a member's UPDATE on peppol_access was an RLS-filtered no-op instead of a permission error (pg-real caught it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9ef7de861f |
feat(peppol): poll outbound delivery status + cap receiving registrations (#1793)
* feat(peppol): poll outbound delivery status from the access point Qvalia's webhook API answers 404 on its production host (the sandbox answers 204), so without this the prod lifecycle would stop at submission_accepted. The transport gains pollDeliveryStatus(); the Qvalia adapter reads /invoices/outgoing/status and maps the message-log status through the same tolerant mapping as a document_delivery webhook, with the same dedupe key, so a later webhook for the same transition is a harmless duplicate. A cron four times an hour walks the open deliveries of the last 45 days, records the answer through the append-only lifecycle RPC and fetches evidence once a delivery reaches transport or a terminal state. Kept as the safety net for a missed webhook once Qvalia ships them to prod. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * feat(peppol): cap receiving registrations at the contracted tenant count The Qvalia partner contract is priced per tenant (10 to start), so the registration refuses the next company with PEPPOL_REGISTRATION_CAP_REACHED once PEPPOL_RECEIVING_MAX_REGISTRATIONS live registrations exist, instead of silently exceeding the contract. A company that already holds a live row is never counted twice; unset means no cap (own provider account). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f93152c397 |
feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery (#1789)
* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery Second Peppol slice (#546). Qvalia confirmed that sending needs no per-company account, so receiving keeps the consolidated partner account: each company publishes its 0007:orgnr on our account and inbound documents are routed by the AccountingCustomerParty endpoint. - PeppolTransport grows optional receiving methods (registerRecipient, unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices / readcreditnotes, exact XML fetch). - lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON (xml2js-style prefixed keys, verified against Qvalia's real inbound test invoice, kept as a fixture) into a neutral document: parties, payment means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines, embedded attachments, credit notes. - Migration 20260821170000: peppol_registrations (one live row per company and participant), peppol_inbound_documents (exact XML immutable and undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability and routing. - POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in Settings > Fakturering; personnummer-based companies are refused until 0088 GLN exists; sandbox refused. - GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver. lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document (upload_source e_invoice, extractionOwner none), an embedded PDF when present, and creates the inbox row with the extraction filled from the UBL (confidence 1, no model pass), matching the supplier by org number. The existing inbox review/convert flow takes over. - document-service accepts application/xml for the archive; inbox list shows a Peppol icon. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES; the pg fixture for a deregistered row now carries deregistered_at as the status-shape constraint requires; the archive insert is an inline literal and the one generic processing-state updater is accounted for in the ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
316189675a |
fix(peppol): read Qvalia's prefixed UBL-JSON keys, add incoming probe (#1786)
Qvalia's UBL-JSON keeps namespace prefixes (cac:AccountingSupplierParty, cbc:EndpointID) with attributes under `$` (verified live 2026-08-21 on the inbound test invoice Joanna sent to 0007:5595386219), not the unprefixed OASIS form the 409-recovery extractor assumed. Accept both. The probe gains `incoming [integrationId]` to list inbound statuses or print one inbound invoice as XML without marking it read. Refs #546 Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
05c3c6ebd9 |
feat(peppol): Qvalia access-point adapter, send flow and delivery webhook (#1780)
* feat(peppol): Qvalia access-point adapter, send flow and delivery webhook Qvalia is the contracted Peppol Access Point (signed 2026-08-21). This fills the provider-neutral PeppolTransport seam from #1595 with a real adapter and turns the disabled "Skicka via Peppol" menu item into a working send flow. Adapter (lib/invoices/transports/qvalia.ts): partner-scoped recipient lookup, XML submission to /invoices/outgoing with integrationId correlation, 409 recovery only when the stored copy carries the same seller endpoint, tolerant mapping of Qvalia's free-text webhook statuses onto the 11-state lifecycle, constant-time shared-secret webhook verification (Qvalia does not sign webhooks), and evidence retrieval of the message-log status plus Qvalia's stored XML copy. Registered from the environment in lib/init.ts; switched on per deployment with PEPPOL_TRANSPORT_PROVIDER=qvalia. POST /api/invoices/[id]/peppol/send: stage the exact XML, look up the recipient, record recipient_verified and submitting, submit, record submission_accepted, then issue a draft with the mark-sent semantics (issueAndBookInvoice) only after the network accepted it. A sync rejection is a terminal failed event so the identical document is never re-sent; an operational failure is retryable; an already-submitted XML replays idempotently. POST /api/webhooks/peppol/qvalia resolves the delivery by integrationId, persists the verified event via the service-role RPC and stores evidence best-effort; unknown submissions answer 200, our own persistence failures 500. UI: the send item is availability-driven with a confirm dialog, the invoice page shows the latest Peppol status, and drafts can be sent (the number is assigned server-side). Probe script for the first sandbox contact under scripts/peppol/qvalia-probe.ts. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): Qvalia sandbox facts from first live contact: bare-key auth, api-test host, SMP-URL document types The onboarding mail and a live probe against the sandbox (partner SE5595386219) corrected three assumptions from the public docs: the key is accepted bare in the Authorization header (the ApiKey prefix answers 401), the sandbox host is api-test.qvalia.com, and the recipient lookup returns document types as SMP service URLs, so capabilities are now normalized to bare Peppol document type ids before comparison. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * feat(peppol): probe commands to inspect and configure the Qvalia webhook subscription Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * fix(peppol): decode UBL entities in one pass (CodeQL js/double-escaping) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4e20c9dec4 |
fix(import): bulk-confirm the VAT-treatment review gate in account mapping (#1723)
* fix(import): bulk-confirm the VAT-treatment review gate in account mapping A Fortnox chart routinely puts 70+ class 3/4 accounts behind the vat-treatment review gate, and the only way through was one Bekräfta click per row across paginated 50-row pages. A live migration (2026-08-18) died exactly there, stuck at 50 kvar with Continue disabled and no way to see why. One outline button next to Continue now accepts the suggested default for every remaining row, with the exact semantics of the per-row button batched (defaults kept, rows marked reviewed). Wired in both the import wizard and the Arcim migration workspace. Strings in sv+en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(guards): two naive-ore-rounds that stacked past the ratchet baseline #1700 and #1705 each added one Math.round(x*100)/100 and each passed CI alone against baseline 630; the first branch containing both trips the ratchet at 631. Convert both to roundOre (629, below baseline). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: place the roundOre import on its own line The previous commit inserted it inside a multi-line import block, breaking parsing in pdf-template.tsx. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: give next build an explicit 8 GB heap The build worker OOMs on the runner's default Node heap since the bundle crossed the default old-space ceiling (first branch containing all of 2026-08-19's merges). Public-repo runners have 16 GB. 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> |
||
|
|
6b9338f17e |
feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund (#1700)
* feat(invoices): Betald-stämpel i faktura-PDF + betalningsbekräftelse till kund Closes #1693. A paid faktura re-renders with a BETALD banner (paid date and amount) and "Betalt: X" followed by "Att betala: 0"; partially_paid gets the Betalt / Att betala (remaining) rows without a banner. Credit notes and proformas are unchanged. Labels in sv and en. The paid copy is its own document, a betalningsbekräftelse, never the archived original: GET /api/invoices/[id]/pdf?variant=paid refuses anything but status paid (409 INVOICE_PAYMENT_CONFIRMATION_NOT_PAID), names the file Betalningsbekraftelse-<nr>.pdf and never reads or replaces the delivery archive. invoice-pdf-source gains the 'payment_confirmation' re-render reason so the UI caveats it like any re-render. POST /api/invoices/[id]/send-payment-confirmation emails the paid PDF with a dedicated subject/body through the existing email service and recipient routing, without touching status, sent_at, journal entries or invoice_deliveries (no kind column there; logged via the route logger instead). Detail page: the two actions sit inside the Betald card (download paid copy, send confirmation with an up-front confirm dialog), not in the header row. No migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): one-line hint for the betalningsbekräftelse actions 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> |
||
|
|
9d59e509ab |
fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX (#1699)
* fix(invoices): fold the ROT/RUT card into Detaljer and mask personnummer as YYYYMMDD-XXXX Founder review of #1690 (2026-08-18), two decisions. Declutter (design B): the separate Skattereduktion card on the invoice detail page duplicated the totals block. It is gone; what it carried beyond the amounts now lives in Detaljer as plain rows, only for invoices with a claim: Personnummer (masked, or "Saknas"), Fastighet (ROT only: fastighetsbeteckning or BRF, with lagenhetsnummer inline), and Skattereduktion with the begaran lifecycle ("Ej begard" + inline "Skapa begaran" link when paid and unclaimed; otherwise the rot_rut_status_* label, date and decided amount), styled like the neighbouring Bokforing row. Totals block unchanged. Per-line subtext shortened to "<RUT|ROT> · <arbetstyp> · <n> tim" (desktop + mobile). Personnummer mask: invoice surfaces now show YYYYMMDD-XXXX (birth date visible, last four hidden), the payroll convention (maskPersonnummer), instead of XXXXXXXX-<last4>. Computed on read from the stored AES-GCM ciphertext by lib/invoices/deduction-personnummer.ts: no schema change, nothing stored, never throws (bad ciphertext logs and renders no personnummer). InvoicePDF derives it itself when given the stored row so no render call site can drop it; the preview route passes an already-masked value (it only has the typed plaintext or the kundkort fallback). The v1 pdf/send routes fetch the ciphertext for the render only; INVOICE_FULL_COLUMNS / INVOICE_PDF_COLUMNS stay as pinned. The detail page and the editor's kept-hint read the mask from the new GET /api/invoices/[id]/rot-rut (withRouteContext, company members), which never returns the last four alongside the mask. v1 REST and MCP keep deduction_personnummer_last4 for compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): stack the ROT/RUT claim state and action in Detaljer At the sidebar card width "Ej begard" and "Skapa begaran" wrapped mid-word side by side (seen in the sandbox on a paid invoice). Same shape as the Bokforing row now: state on top, the action under it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
619b446c52 |
fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction (#1685)
* fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction The Swish payment QR on invoice PDFs encoded the pre-deduction invoice total (getDisplayTotal), while the totals block and the invoice email state "Att betala" as total minus the ROT/RUT deduction (getAmountToPay, fakturamodellen). Since the Swish payload locks the amount (editmask 0), a customer scanning a RUT/ROT invoice was asked to pay the full total with no way to correct it: overpaying by the entire skattereduktion. Swap the QR amount source to getAmountToPay(...).toPay so the QR, the printed "Att betala" and the email always agree. A fully deducted invoice (toPay = 0) now renders no QR via the existing amount > 0 guard. All seven render surfaces (send, preview, pdf, v1 send/pdf, MCP commit, recurring, issue-and-book) go through this one helper. Reported by a user: "QR-koden for swish stammer INTE med beloppet man ska betala. Den tar INTE hansyn till reduktionen." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): select the amount-to-pay columns on the v1 pdf and send surfaces Skeptic review of the Swish QR fix found it was a silent no-op on the v1 GET pdf route: its column projection predated ROT/RUT and omitted deduction_total (and ore_rounding), so getAmountToPay saw undefined, treated it as "no deduction", and the route kept emitting a locked full-amount QR while the sent email said the deducted "Att betala". INVOICE_FULL_COLUMNS (v1 send renders from it) likewise omitted ore_rounding, ignoring the per-invoice oresavrundning override there. Move INVOICE_PDF_COLUMNS into lib/api/v1/invoice-columns.ts, add deduction_total, deduction_personnummer_last4 and ore_rounding to it, add ore_rounding to INVOICE_FULL_COLUMNS, and pin the amount-path columns of both projections with a test: a projection gap does not error, it renders the wrong money on one surface only, so it must be caught structurally. Also records the defect and remediation in DECISIONS.md per the compliance-swarm change-risk finding (the repo has no risk_register.csv; the decision log is its equivalent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): gate the Swish QR to payable documents and restore delivery_date on the v1 pdf Swedish accounting review round 2: buildSwishQrDataUrl had no non-payable gate, so a kreditfaktura (a refund document) still produced a locked Swish payment QR at helper level; the template happens to hide the payment box for credit notes, but a payment request against a refund must stay impossible rather than merely unrendered. Apply the same document gate buildPaymentLinkQrDataUrl already has (invoice documents without credited_invoice_id only) and pin it with tests replacing the credit-note parity case. Also add delivery_date to INVOICE_PDF_COLUMNS: ML 17 kap 24 p.7 requires leveransdatum on the invoice when it differs from the invoice date, the template renders exactly that, and the v1 pdf projection silently dropped it. Same projection-starvation class as the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(invoices): name the covered render surfaces and drop the contested lagrum point number CodeRabbit round 3, both documentation-only: the DECISIONS defect record said "all surfaces" while the editor preview is deferred to #1686, so it now lists the covered surfaces explicitly; and the delivery_date comment cited ML 17 kap 24 p.7 where CodeRabbit reads p.8 in SFS 2023:200 while the repo's swedish-invoice-compliance reference table says p.7, so the citation drops the point number and stays at the paragraph, which is correct under either enumeration. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2b5b813b7a |
feat(invoices): rebuild the invoice editor as the snabbflöde single column (#1654)
* refactor(invoices): extract editor payload builders with parity tests Extract the three near-identical inline payload builders in InvoiceEditor.tsx (handleConfirm, saveDraftData, saveEdit) and the self-billed body mapper into pure functions in lib/invoices/editor-payload.ts. Zero behavioral change: the new lib module carries a 300-case parity suite asserting JSON byte equality against verbatim copies of the legacy inline recipes across the full mode x deduction x dimensions x ore-rounding matrix. This is the byte-compatibility ratchet under the upcoming editor re-layout: the repo renders no components in tests, so the wire bodies are what CI can pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rebuild the invoice editor as the snabbflöde single column Reshape InvoiceEditor to the approved prototype: one 640px column with uppercase section labels and honest state marks (RequiredMark asterisks, sage check on a picked customer, muted row counts), a dense in-table rows surface with a unified last-row entry (autocomplete over the artikelregister, italic ghost cells, Enter commits free text and lands in the price cell, ArrowDown+Enter commits an article through the same applyArticle side effects), hover-revealed 24px row controls with 40px coarse-pointer targets and per-row aria-labels, a Förval chip line whose collapsed settings re-surface as chips whenever a value deviates from its default (critical in edit/copy so PATCH never round-trips invisible values), a single ochre next-step line (aria-live polite) that doubles as the invalid-submit focus router, and a sticky bottom action bar with the live total: position sticky in both hosts, never fixed, since DialogContent's transform re-anchors fixed children in bare mode. Behavioral deltas, all pre-decided: the primary action is never disabled pre-click for writable users (viewers keep the lock+tooltip treatment); client-side validation failures route focus instead of toasting; genuine field errors stay terracotta and field-adjacent while the two ochre disclosures (taxed-where-performed, labor-only) demote to muted text; committed free-text rows expose a quiet Spara-som-artikel link; the review dialog lists the applied förval (currency, öre rounding, payment-link state); a freshly committed row gets a brief background settle that collapses under prefers-reduced-motion. ArticleCombobox gains the missing combobox ARIA (listbox/option roles, aria-controls, aria-activedescendant only after explicit arrowing). New pure module invoice-editor-flow.ts pins the next-step priority order, the Förval chip derivation and the suggestion filter with unit tests. All payload builders, submit targets and the VAT baseline refs are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): editor review nits: orphaned keys, housing gate, listbox ARIA Three review findings on the snabbflode editor: - Delete 13 orphaned invoice_editor keys from both message files (subtitle_*, add_row, remove_row, remove_row_aria, details_card_title, save_as_draft_short, validation_toast_*, delivery_date_placeholder); each verified unused on the branch, sv/en parity kept. - Gate the housing next-step on a claimed deduction amount so it matches the ROT/RUT claim card's mount condition: a ROT-flagged line with a zero amount mounts no card, and the ochre link would try to focus an unmounted field. Extracted as deriveRequiresHousing in the flow module with a test proven to fail on the old gate. - Move the entry-row popover hint out of the role=listbox element (listbox children must be options) into a sibling inside the absolute wrapper, referenced via aria-describedby on the combobox input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): drop the in-editor faktura/sjalvfaktura tabs The Ny faktura split button already chooses the mode (?self=1); a second switcher inside the editor was double steering. The mode is now fixed for the editor's lifetime and the heading (Registrera sjalvfaktura) carries the distinction. Orphaned tab keys removed from both message files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): wrap sticky-bar actions so they fit small viewports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): stop dialog grid item overflowing small viewports min-w-0 on the editor root: DialogContent is display:grid, so the row grid's min-w otherwise forces the column past narrow screens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): lift assistant FAB above the standalone editor's action bar The rebuilt editor introduces the first page-level sticky bottom bar; the assistant FAB (fixed, z-30) covered its Spara/Granska buttons on the /invoices/[id]/edit page. The editor now sets body[data-page-bottom-bar] in non-bare mode and AgentTrigger lifts to bottom-20 when it is present. 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> |
||
|
|
798a76ed7a |
fix(invoices): accept USD/GBP payment accounts without an IBAN (#1649)
Payment accounts per currency required an IBAN for every non-SEK currency. USD (ABA routing number) and GBP (sort code) accounts have no IBAN, so a Wise US or UK receiving account could only be saved by pasting an IBAN from another currency, which then printed on the invoice and misrouted the payment. - InvoicePaymentAccount gains bank_code (routing number / sort code) and foreign_account_number; JSONB column, no migration. - Rule, shared by the Zod schema, the client validation and hasUsableInvoicePaymentAccount: a foreign account is usable with an IBAN, or, only for NON_IBAN_CURRENCIES (USD, GBP), with bank_code + foreign_account_number + BIC. EUR/NOK/DKK still require IBAN. - Settings: the two fields appear only for USD/GBP with the identifier named per currency (Routing number (ABA) / Sort code), a hint that IBAN may be left empty, and IBAN no longer marked required there. - Invoice PDF renders the routing row with the same per-currency label plus the foreign account number, in both sv and en. Reported via gnubok_feedback 2026-08-03. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e030393fe6 |
fix(rot-rut): payment-side booking, reminders and claim completeness (#1652)
* fix(rot-rut): payment-side booking, reminders and claim completeness Follow-ups from the 2026-08-17 ROT/RUT audit (dev_docs/rot_rut_audit_2026_08_17.md). Payment side (fakturamodellen: the customer pays total minus avdraget, the rest is a 1513 receivable on Skatteverket): - createInvoicePaymentJournalEntry without an explicit paymentAmount used to book invoice.total on 1930/1510. Every no-lines mark-paid path (MCP mark_invoice_as_paid, v1 API, no-body dashboard route, Stripe) settles the outstanding amount, so on a ROT/RUT invoice 1510 went negative by the deduction and 1930 was overstated; same defect for any previously part-paid invoice. It now books the outstanding amount (remaining_amount, else total minus paid_amount); a fully outstanding invoice keeps the total_sek path. - proposePaymentLines had no deduction awareness: the payment dialog pre-filled D1930 total / K1510 total, which the settlement plan rejected as an overpayment, so a ROT/RUT invoice could not be marked paid from the UI. Accrual: bank + 1510 carry total minus avdrag; cash method: bank gets the customer share, 1513 the avdrag, revenue + moms in full. Foreign invoices without a booking rate refuse (1513 is a kronor receivable). Dialog passes deduction_total. - Reminders and dröjsmålsränta were computed on invoice.total: a privatperson was dunned for the Skatteverket share and charged interest on it. New reminderPrincipal() = the invoice's "Att betala" (öre-rounded total minus avdrag) drives the processor's interest base and all three templates. Claim completeness (HUSFL 2009:194: art av arbete + antal arbetstimmar): - work_type and labor_hours were optional at creation but hard blockers at begäran-file time, when the invoice is numbered, booked and paid and cannot be edited. validateDeductionLines() now requires a same-kind arbetstyp and hours > 0 (schablontjänster exempt) on every deduction line; wired into validateInvoice, CreateInvoiceItemSchema (field-level issues) and the editor schema with inline errors under the ROT/RUT strip. Fixed the labor_hours register (valueAsNumber overrode setValueAs: an emptied field became NaN and failed validation with no visible error). The Underlag card now shows whenever any row is flagged, matching the payload/server predicate. Yearly ceilings: - COMBINED_MAX 75 000 kr: ROT + RUT share one ceiling per person (ROT capped at 50 000 inside it). deductionCapWarnings() carries the per-kind and the combined check plus optional prior-year totals; validateInvoice forwards them; the editor uses the same helper and fetches what the customer has already been granted in the invoice year (per customer, warning only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): treat remaining_amount left at DEFAULT 0 as unmaintained when booking a payment Rows written by paths that bypass buildInvoiceWriteData (imports, sandbox seed, legacy migrations) carry remaining_amount = 0 while unpaid; prod has ~330 such open invoices. Booking 0 would have failed the engine's positive- amount rule, so the outstanding helper derives total - paid - deduction when the stored value is not positive. Test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): review follow-ups on #1652 - ROT/RUT completeness moves to the invoice-level schema (CreateInvoiceSchema / UpdateInvoiceSchema share one refine) so it only applies to real invoices and skips text rows; the editor gates its mirror on the document type via a ref. Tests moved accordingly (CodeRabbit). - Prior-year deduction lookup follows the PAYMENT year (paid_at, else invoice_date for open invoices), paginates via fetchAllRows, and clears the total on a failed request instead of leaving a stale one. - rot-rut-file derives its schablon flags from SCHABLON_WORK_TYPES so the validator and the generator cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(rot-rut): pick the prior-year deductions client-side (phantom-columns ceiling) The runtime-built .or() filter counted as an unresolvable query expression for the no-phantom-columns guard. A customer has few deduction invoices, so fetch them all and select the payment year in code. 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> |
||
|
|
79240cb2ed |
fix(articles): article ROT/RUT prefill was dead for every dashboard-created article (#1651)
* fix(articles): article ROT/RUT prefill was dead for every dashboard-created article Follow-up to #1634. The user re-tested and picking a RUT article still left the line on "Ingen": the article form has always stored the bare kind ('ROT'/'RUT'), while the prefill only recognised Skatteverket work-type codes (BYGG, STAD, ...). On prod every dashboard-created ROT/RUT article holds the bare kind, so the fix in #1634 never fired for a real user, and worse, since the helper returned null for those values, picking such an article CLEARED a deduction the user had set manually on the row. - rot-rut-rules: parseArticleHouseworkType() understands both vocabularies (code -> kind + arbetstyp; bare ROT/RUT -> kind only), plus normalizeHouseworkType()/HOUSEWORK_TYPE_VALUES/workTypeLabel(). - InvoiceEditor.applyArticle: kind-only articles pre-fill the deduction and keep a same-kind arbetstyp already chosen on the row; "Spara som artikel" round-trips the code or, lacking one, the kind. - ArticleForm: the ROT/RUT select now offers the real Skatteverket arbetstyper in ROT/RUT groups (its own hint always promised "förifyller arbetstyp"); legacy kind-only values stay selectable as "RUT (arbetstyp ej vald)" so an edit never silently drops the flag. Article detail renders "RUT · Städning" instead of the raw code. - API + MCP commit schemas normalize housework_type (case-insensitive code or ROT/RUT, '' clears) and reject anything else; the CSV article import normalizes the column the same way. Prod holds 178 articles with '0'/'1' from a boolean "Rot" column that the keyword detector mapped straight through; those now read as no flag everywhere and can no longer be created. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(articles): review follow-ups on #1651 - InvoiceEditor: switching a row's skattereduktion ROT<->RUT clears an arbetstyp from the other list, and Spara som artikel only round-trips a work type that belongs to the row's kind (CodeRabbit). - MCP update_article: null / '' / whitespace now clear housework_type (commit drops only undefined keys, so the old undefined mapping made the flag un-clearable); create keeps treating them as unset. Tests. - Article CSV import warns when a non-empty ROT/RUT value is dropped as not-an-arbetstyp instead of dropping it silently. Test. - Hint wording: arbetstyp is pre-filled only when the article carries one. 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> |
||
|
|
caa0c3b41d |
feat(mcp): accept article_id on gnubok_create_invoice lines (#1638)
* feat(mcp): accept article_id on gnubok_create_invoice lines Invoice lines staged via MCP can now reference a catalog article (artikelregister). Staging prefills description, unit, unit_price, vat_rate and revenue account from the article with explicit-wins semantics, mirroring the web line picker. Unknown, foreign-company and deactivated articles are refused at staging, as is a price prefill from an article priced in another currency. The approval executor gains a company-scope gate for staged article_id values: the FK on invoice_items.article_id proves existence, not tenancy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): adopt article VAT rate only from the customer's default set Web line picker parity (InvoiceEditor's applyArticle guard): an article's stored vat_rate is its domestic rate. The prefill previously staged it unconditionally, and the staging/commit gates check the wider permitted set (which includes 25/12/6 for taxed-where-performed supplies), so {article_id, quantity} to a validated EU business staged 25% Swedish VAT onto a reverse-charge invoice. The customer is now fetched before the prefill and the article rate is adopted only when it is in the customer's default rate set; foreign-business lines fall back to the 0% reverse-charge/export default unless the agent sets vat_rate explicitly. Prefill logic extracted into resolveInvoiceLineFromArticle (CodeRabbit). Found by the skeptic review pass (two independent refutations) and the Swedish accounting compliance bot, all converging on the same defect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): name the article VAT adoption set in vat-rules, not the MCP server The vat-rate-gate-parity guard pins that no invoice write path mentions getAvailableVatRates: gating on the picker default is the bug it exists to prevent. The article-rate adoption in gnubok_create_invoice needs the default set for a different purpose (prefill, not gating), so the semantics move into lib/invoices/vat-rules.ts as getArticleVatRateAdoptionSet(), with tests pinning that adoption is empty for single-rate foreign customers and always a subset of the permitted set. server.ts keeps gating on getPermittedVatRates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
62c6fc44fe |
fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim (#1634)
* fix(invoices): article pre-fills ROT/RUT and kundkort personnummer covers the claim Two gaps reported by a user invoicing RUT work: - Picking an article with a housework_type (arbetstypskod) left the line's skattereduktion on 'Ingen': the editor never fetched the field. applyArticle now derives deduction_type from the code's Skatteverket list (disjoint ROT/ RUT lists, new deductionTypeForWorkType helper) and sets work_type, with the same overwrite semantics as description/price: an article without a code clears the deduction so a material article never keeps claiming one. 'Spara som artikel' round-trips the code back onto the created article. - The customer card's personnummer was never used for the ROT/RUT claim; the user had to retype it per invoice. The browser only ever sees ciphertext or a mask, so the fix is a server-side fallback in buildInvoiceWriteData: typed > stored draft > kundkort. The kundkort value is decrypted, expanded to 12 digits (new expandPersonnummerTo12, century inference incl. '+' and samordningsnummer), Luhn-validated, and encrypted into the invoice; invalid or unreadable values fall through to the existing 'Personnummer krävs' error. The editor drops the required-mark and hints that the number comes from the kundkort when one exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): gate the kundkort personnummer fallback on individual customers ROT/RUT is a privatperson deduction; customers.personal_number is individual-only in the Zod schemas but not in the DB, so a stray value on a business row must never be claimed on implicitly. Typed values unaffected. Raised by the compliance review bot on #1634. 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> |
||
|
|
07e89d9b52 |
feat(invoices): add Peppol delivery foundation (#1595)
* feat(invoices): add Peppol delivery foundation * fix(invoices): harden Peppol compliance guards * fix(api): narrow Peppol document loading * test(pg): hash Peppol fixture payload * fix(invoices): address Peppol review findings * test(pg): isolate Peppol provider events * test(pg): isolate Peppol submission fixtures |
||
|
|
22eaab82e5 | feat(invoices): add Peppol XML export foundation (#1585) | ||
|
|
bffa57a565 |
feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA (#1533)
* feat(invoices): bulk Bokfor, per-view filter counts, review-queue draft CTA Customer feedback: MCP-created invoices land in Granskning and then sit as unnumbered drafts that each need individual issuance, and the list filter gives no signal about where the work is. - New POST /api/invoices/bulk-book: drafts get an F-number + mark-sent semantics (no email) and book inline when the company books at issue; sent/overdue unbooked invoices get the deferred /book semantics. Sequential loop keeps voucher numbers ordered; per-item Swedish errors. - Extracted the shared cores into lib/invoices/issue-and-book-invoice.ts and lib/invoices/book-invoice-deferred.ts, now used by the per-id mark-sent and book routes AND the bulk loop, so they cannot drift. Per-id route behavior unchanged (existing route tests untouched, green). - Invoice list: multi-select with hover-reveal checkboxes (supplier-invoices shape), bulkbar with mode-aware action label, ConfirmationDialog with a draft/sent breakdown, one aggregate toast. Kontantmetoden hides selection entirely. - ContextPicker: count annotations on every status view via the one shared predicate (counts always match rows), active view written back to the URL (?status=) for shareable views. No seg/chip row: founder-locked pattern. - Granskning: after a bulk approve that committed create_invoice ops, the summary toast links to /invoices?status=draft to finish with bulk Bokfor. Verified: npm run lint clean, npm test 13845 passed, npm run check:guards passed. New tests: bulk-book route (11), issueAndBookInvoice (7), bookInvoiceDeferred (7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): bulk-book review findings, deferred drafts, dupes, URL params - Deferred-booking companies (accrual + defer_invoice_booking): a draft in bulk-book no longer gets silently ISSUED (F-number consumed, marked sent, invoice.sent emitted) while reporting status 'booked' with a null journal_entry_id. The draft branch now requires booksInvoicesOnIssue(); otherwise the item fails per-row with the new INVOICE_BOOK_DEFERRED_DRAFT code (Swedish + English) before the invoice is touched. - Duplicate ids in one request no longer double-book: the second iteration read the stale pre-loop snapshot, passed the already-booked check, and minted a voucher the CAS claim then cancelled (cancelled verifikat + gap explanation per duplicate). Ids are deduped before the loop. - Bulkbar: the select-all link is hidden when the current view has no selectable rows; "Markera alla (0)" only wiped the existing selection. - Invoice dialog open/close handlers (new invoice, self-billed, ROT/RUT payout) rewrite only their own query keys instead of hardcoding '/invoices', so the ?status= view write-back survives them. - /pending: the "Bokfor utkasten" toast CTA is suppressed for kontantmetod and deferred-booking companies where the invoice list offers no draft bulk Bokfor (dead end); the neutral hint sentence stays. Tests: deferred-draft rejection (asserts issueAndBookInvoice never called, sent invoice in the same batch still books) and duplicate-id dedupe (exactly one booking call); both fail without the route fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9dbaebcc50 |
fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)
* feat(invoice-inbox): store HTML mails as underlag, expandable field editor Body-only mails and .html attachments (including forwarded .eml bodies) no longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a self-contained text/html document, stored through the normal upload/extract pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail itself can serve as bookable underlag. Empty mails keep the error row, unsupported types are still rejected, and webhook retries dedupe on resend_email_id. Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe sandbox in the workspace preview and a CSP sandbox header on /api/documents/:id/inline for text/html. The type is accepted only from the email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload. The "Extraherade falt" rail gains an expand button opening a centered dialog with the same autosaving field editor at a readable size (two columns), which also gives every failed or skipped extraction a manual fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): sortable verifikat list headers with amount sort - clickable sort toggles on the verifikat list headers (asc -> desc -> default) - total_amount computed column + sort_by total/description on the list route - failed list loads render an error card with retry, never the empty-ledger state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): decode bank CSV as Windows-1252 fallback in column mapping The client read the uploaded file with file.text(), which is UTF-8-only, so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with U+FFFD in place of Swedish characters. Decode from bytes with the shared decodeFileContent() helper, matching what the server parse route does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): stackable sort keys on verifikat list headers - shift-click adds a column as secondary/tertiary sort key (max 3), plain click keeps the single-key tri-state cycle - sort_by accepts a comma-separated priority list; single tokens stay valid - voucher tiebreak follows the last key's direction (#972 parity) - priority numbers on stacked headers; hint text in the filter dialog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep ROT/RUT deduction fields positive on credit notes Crediting an invoice with a ROT/RUT deduction failed 100% of the time: the credit-note path negated deduction_total (and per-item deduction_amount) like the other amounts, but both columns carry CHECK (>= 0), so Postgres rejected the insert and the user only saw 'Kunde inte skapa kreditfaktura'. Store the deduction fields as positive magnitudes, matching the convention everywhere else. The stored sign is inert on credit notes: the reversing verifikat recomputes the ROT/RUT split from the items, and the PDF and amount-to-pay logic skip deductions on credit notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(transactions): share the source chip across inbox and history modes Move SourceFilter to transaction-types.ts (widened with 'bank:other' and 'acct:<id>'), render the one toolbar ContextPicker in both view modes, and drop the narrower duplicate chip inside TransactionHistoryList. The history list now applies the acct:/bank:other narrowing itself and hides skattekonto rows under any bank-side selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 4.3.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): recognize PostgREST computed columns in the migration parser The verifikat amount sort orders by total_amount, a PostgREST computed column (a function on the journal_entries row type, migration 20260811100000). The schema guard only modeled real columns, so no-phantom-columns flagged the order as a phantom. Teach the parser that a function whose only argument is a table's row type joins that table's column set, with DROP FUNCTION retraction when the signature names the row type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve PR #1523 review findings - journal-entries route: append the globally unique id tiebreak to every direct-query sort; voucher series+number repeat across fiscal years, so the all-years scope could duplicate or drop rows at page boundaries. Existing order assertions updated, new all-years tiebreak test. - documents inline route: CSP source policy on HTML previews; sandbox alone still loads remote resources, letting a tracking pixel notify the sender on open. New route test asserts the full header. - JournalEntryList: catch rejected list requests so loading cannot stick forever, and gate every post-await state write behind a request generation so a slow earlier request cannot overwrite the current sort. - TransactionHistoryList: pagination follows the selected source scope (reachable with zero matches on the current page, hidden for the skattekonto scope it cannot affect). - transactions page: bank:other picker availability derives from history rows too, not only the pending inbox dataset. - DECISIONS.md: mark the superseded single-sort decision; record the credit-note deduction positive-magnitude invariant and its verified reader inventory (Swedish review flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard metadata refetches behind the list request generation fetchAttachmentCounts and fetchRattelseFlags write state after their own awaits; a stale list request's late completion could overwrite attachment counts and rattelse flags for rows a newer request just rendered, showing false missing-underlag warnings. Both helpers now take the caller's generation guard and discard stale completions, including the attachment-counts loaded flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd344b6dbb |
fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries check_balance_on_post only fires on the draft-to-posted UPDATE transition, so any code path that INSERTs a row with status 'posted' directly skipped balance validation entirely. The invariant sum(debit) = sum(credit) on every posted entry was DB-enforced only for the engine's commit lifecycle. Add check_balance_on_posted_insert, a deferred constraint trigger on AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing check_journal_entry_balance() function, which already handles the journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics let an atomic transaction insert header and lines together; zero-line and unbalanced posted inserts are rejected at constraint evaluation. All existing checks stay intact; this only adds coverage. The one first-party posted-INSERT path outside an RPC, the sandbox seed, now books through the bookkeeping engine (createJournalEntry) instead of raw inserts. SIE import already inserts header and lines in a single transaction via its structured RPC and passes unchanged. pg tests cover the new path (zero-line rejected, unbalanced rejected at SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and existing posted-entry fixtures move to a transactional insertPostedJournalEntry helper so they stay valid setup. Fixes #327 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): insert list-filters pg fixtures in one transaction The list-filters suite (landed via a sibling merge) inserted posted headers with getPool().query, where each query autocommits: the deferred check_balance_on_posted_insert constraint fired at the header's own commit with zero lines and correctly rejected the fixture. Header and balanced lines now share one BEGIN/COMMIT so the constraint evaluates the complete entry, mirroring the insertPostedJournalEntry helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(seed): insert journal headers as drafts, post after lines land check_balance_on_posted_insert (renamed to apply-time version 20260806130000) rejects a posted header whose transaction has no lines. PostgREST autocommits each request, so every seed path that inserted posted headers first would die with "has zero total": the sandbox seed (ledger history, invoice vouchers, salary vouchers), seed-demo-account and seed-export-data. All now insert draft headers, insert lines, then flip to posted so check_balance_on_post validates the finished verifikat. The sandbox seed keeps its documented no-events design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve a preset committed_at on draft-to-posted transition set_committed_at() stamped now() unconditionally, so the seed flows that post backdated drafts lost their historical booking timestamps and every demo verifikat read as booked today (CodeRabbit finding on PR 1439). Stamp only when committed_at is NULL: the engine path (drafts carry no committed_at) behaves exactly as before and a posted entry still always has a committed_at; an explicitly supplied value now survives posting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): preserve preset committed_at only for trusted roles The IS NULL guard alone (20260806150000, never shipped; replaced by 20260806160000) let any RLS-permitted member backdate committed_at through PostgREST by presetting it on a draft and posting, which the Swedish accounting review flagged: committed_at is what the BFL 5 kap timeliness checks and behandlingshistorik treat as the genuine transition time. Preset values now survive posting only for service_role/postgres/supabase_admin; authenticated and anon writers always get the now() stamp. Consequence: the sandbox seed (runs as the requesting user) gets committed_at = posting time, accepted and documented in the route; the demo scripts run as service_role and keep their backdated history. pg tests cover all four paths, with the upper timestamp bound CodeRabbit asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): restore superseded migration so the preview tracker stays consistent The preview branch had already applied 20260806150000 when the previous commit deleted the file, orphaning the preview's migration tracker ("Remote migration versions not found in local migrations directory"). Restored with a header explaining it is superseded in the same deploy by 20260806160000, so the unguarded semantics are never live on their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): decide committed_at trust by JWT claims, not current_user The Swedish review found the current_user guard bypassable: commit_journal_entry is SECURITY DEFINER and granted to authenticated, so inside it current_user is the function owner and a member could preset a backdated committed_at on a direct-inserted draft and launder it through the RPC. The guard now reads the JWT claims role (same primitive as the RPC's own tenant guard): preset values survive only for service_role or claim-less backend connections; authenticated and anon callers are always stamped now(), on both the direct UPDATE and the RPC path (new pg test). Both migration files now carry the identical final body so no unguarded intermediate exists as a standalone applyable unit. Behandlingshistorik logging of trusted overrides is follow-up #1444. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a0ca692fed |
feat(invoices): quarterly, half-yearly and yearly recurring invoice schedules (#1438)
* fix(mcp): offer the link tool in the uncategorized-transactions VAT blocker The gnubok_vat_close_check blocker hint only named categorize/auto-match, both of which create new bookkeeping. For a transaction whose affarshandelse is already booked on an existing verifikat, following the hint would double-book, so agents dead-ended the case into "contact support" (2026-08-06 support mail from Orto Engineering). The hint now also names gnubok_link_transaction_to_journal_entry, is extracted as an exported constant pinned by a test, and the tool joins the categorize_month recommended loadout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): quarterly, half-yearly and yearly recurring schedules User request: recurring invoice schedules only supported monthly cadence. Adds interval_months (SMALLINT 1-12, default 1) to recurring_invoice_schedules; the UI offers manadsvis/kvartalsvis/ halvarsvis/arsvis presets while API and MCP accept any 1-12. The cron advances next_run_date by whole intervals from the due date, and the new rollNextRunDateForward() helper rolls missed or edited interval schedules on their own month grid so a quarterly Jan/Apr/Jul/Oct schedule missed in an outage rolls Jan 15 to Apr 15, never Feb 15. Monthly (interval 1) keeps its existing today-anchored recompute semantics unchanged. Changing the interval alone never touches next_run_date: the new cadence applies from the next run, so an edit can never pull a send earlier. Existing rows default to 1 and behave byte-identically. The MCP slice of this feature (interval_months on the three recurring-schedule tools in server.ts) was committed in d2600907f alongside the VAT-blocker hint fix by a parallel session sharing this worktree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): address PR #1438 review findings CodeRabbit round 1, all three findings: - MCP descriptions now state the full accepted interval range (any integer 1-12) instead of enumerating only the 1/3/6/12 presets, and qualify that changing ONLY interval_months leaves next_run_date untouched. - assertValidCadence rejects fractional day_of_month. - rollNextRunDateForward rejects calendar-invalid anchors that pass the shape regex (2026-13-05, 2026-02-31), with regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |