c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
131
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
00ae3540db |
feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical DTOs, customer APIs (web + v1) and invoice send flows so contact person and customer-level invoice CC/BCC addresses survive provider migrations. NULL means unconfigured and empty means an explicit clear, so re-syncs enrich legacy gaps without resurrecting deliberately removed values. Fortnox fixed assets are split into a dedicated follow-up issue. Fixes #1345 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): bump customer metadata migration past pack-slug version Main already contains 20260803230000; keep new versions strictly newest so Supabase branching applies them in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): complete Customer type consumers and make enrichment payload resolvable The preview-pdf mock customer and the makeCustomer fixture now carry the three new metadata fields, fixing the type-check failure in Build (zero extensions) and Vercel. The enrichment update in the migration orchestrator now spells its payload as an object literal typed CustomerMetadataEnrichment (absent keys drop at serialization), so the phantom-column guard resolves the columns instead of counting another unresolvable dynamic payload past its ceiling. The cc/bcc guards also verify element types instead of casting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e40dc64485 |
feat(invoices): invoice list column sorting and row-level status styling (#1375)
* feat(invoices): add column sorting and row-level status styling to invoice list Client-side sorting on number, customer, due date, amount and status with Swedish collation, null-last ordering and stable date/id tie-breaks. Status chips move to the shared RowStatus descriptor so normal states stay muted and exceptions carry semantic color. Invoice fetch now pages past the PostgREST 1000-row cap via fetchAllRows so sorting covers the full list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): make rounded-amount sort assertions non-degenerate Distinct rounded totals now prove the comparator orders by displayed value; the rounded tie case is kept as an explicit tie-breaker test since integer rounding is monotonic and can only create ties, never reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
00d4c8a49e |
feat(invoices): ROT/RUT payout file dialog and file guards (#1380)
* feat(invoices): ROT/RUT payout file dialog and file guards Rebuild the UI for the existing headless HUS V6 payout-file flow (demanded via #789): a dialog on the invoices page to pick eligible paid ROT/RUT invoices, generate the XML, download it and track request status. Adds file-level guards from the Skatteverket spec: future payment dates blocked, one file per payment year, max 100 cases per file, with per-invoice blocker messages. Submission stays manual (upload + sign in the SKV e-service); no direct submission API exists. Fixes #789 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): compute rot-rut gating date in Europe/Stockholm The candidate and begäran date defaults used the UTC calendar day, which near midnight Swedish time could wrongly block or admit an invoice via FUTURE_PAYMENT_DATE and shift the 31 January deadline warning. Use getSwedishLocalDate() like the bookkeeping engine. Raised by the Swedish compliance review on PR #1380. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cd7d7f52b9 |
feat(invoices): per-recipient email delivery outcomes (#1384)
* feat(invoices): per-recipient email delivery outcomes Resend delivery webhooks identify affected addresses in data.to, so one message with CC recipients can carry independent To/CC outcomes instead of masking the failing address into the aggregate reason text. - new apply_invoice_delivery_provider_event RPC merges each reported recipient onto its immutable To/CC position with the same rank and timestamp ordering as the aggregate status (retry and out-of-order safe) - recipient map is PII-free: keyed to:N / cc:N, BCC and unmatched recipients are never represented, and the map is cleared on PII redaction - delivery summaries, API route and MCP tool expose the sanitized map; the route re-sanitizes as defense in depth - UI shows a per-recipient status list under the aggregate outcome The prod ops check in issue #1350 (webhook registered in Resend and RESEND_DELIVERY_WEBHOOK_SECRET set in Vercel) cannot be verified from the repo and remains a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): commit provider event before cross-context read The BCC-leak test applied the event inside the rollback-scoped service role helper and then asserted through a separate member context, so the applied status was rolled back before the read. Use the committing runAsServiceRole helper for the apply, matching how the summary read is performed in its own context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9e54a8e400 |
fix: preserve invoice payment dates (#1332)
Signed-off-by: Emil <emilmattsson14@gmail.com> |
||
|
|
27ae59040e |
fix(transactions): retire stale invoice match pointers when an invoice settles (#1313)
* fix(transactions): retire stale invoice match pointers when an invoice settles potential_invoice_id / potential_supplier_invoice_id are write-once import suggestions: nothing revisited them once written. With recurring same-amount invoices, an earlier suggestion pointed transaction A at invoice X, X was then paid off by transaction B, and A kept pointing at a fully paid invoice. The match dialog computed its amount diff against that invoice's 0 kr remaining_amount and reported a bogus partial payment, and the dead pointer also blocked a fresh suggestion: both re-suggestion scans require the column to be NULL. Add one shared helper, clearSettledInvoiceSuggestions(), that nulls a settled invoice's own suggestion column on every other transaction of the same company, scoped by company_id and by that invoice id only, never widening to the confirmed invoice_id / supplier_invoice_id links. It is best effort by construction: every caller has already booked a payment verifikat, so a failed cleanup logs and returns instead of failing the settle. Wired into every path where an invoice reaches paid through a payment: the dashboard and v1 match-invoice / match-supplier-invoice routes, the dashboard and v1 mark-paid routes, settleInvoicePayment, the batch allocation route (per fully settled allocation), linkInvoiceToVoucher and linkSupplierInvoiceToVoucher, linkTransactionToJournalEntry, and the MCP staged-operation executors for mark_invoice_paid and match_transaction_invoice. Partial payments are deliberately left alone: a partially paid invoice is still matchable. The v1 supplier match route also clears its own row's hint, which it was missing next to its dashboard twin. Read-time revalidation stays as the backstop for the paths not wired up here. countSuggestedMatches now delegates to listSuggestedMatches, which already revalidates candidates, so the worklist badge can no longer claim a number the list refuses to render. A data-only backfill migration retires the pointers already stranded in the database. It touches no journal entry, verifikat or period-locked data, is idempotent, and its status lists mirror lib/invoices/matchable-statuses.ts. Fixes #1259 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): wire the MCP batch allocation into the settled-pointer cleanup Review follow-up on the #1259 fix. commitMatchBatchAllocate calls the same match_batch_allocate RPC as the dashboard route, and gnubok_match_batch_allocate is a live staged MCP tool, so an agent settling a samlingsbetalning reproduced the issue exactly: the RPC nulls potential_invoice_id / potential_supplier_invoice_id only on the source transaction, leaving every other transaction of the company pointing at an invoice the batch just closed. The per-allocation loop moves into clearSettledBatchAllocationSuggestions() so the HTTP route and the MCP executor run the same code and cannot drift again, with a commit-path test pinning that only the fully settled allocation is retired. The enlarged badge scan is made safe. countSuggestedMatches now feeds up to 200 ids into listSuggestedMatches, past the 150 per .in() that countInboxDocuments already chunks for, so the candidate lookups are chunked at IN_CLAUSE_CHUNK too and their ids deduped. Both lookups now check .error: previously a 414, a 500 or an RLS change produced empty maps, an empty list and a zero badge with nothing logged. Every failure branch here logs companyId, matching the logAndZero convention. Also: restore the anchorSupplierInvoiceDocument doc comment above its own call in the dashboard supplier-invoice mark-paid route (the #1259 block had been inserted between them), and assert the transaction update payload in the v1 match-supplier-invoice test, which now covers the potential_supplier_invoice_id null that the route was missing next to its dashboard twin. Fixes #1259 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f3bf50d862 |
fix(invoices): roll back the header row when a recurring-schedule item replace fails (#1312)
* fix(invoices): roll back the header row when a recurring-schedule item replace fails
PATCH /api/invoices/recurring/[id] and the update_recurring_schedule commit
executor wrote the schedule header first, then replaced the items. An item
insert failure restored the items snapshot but left the header update
committed, so a combined edit half-applied: a new day_of_month or
default_dimensions stayed while the line edit was undone.
Both write paths now go through one shared helper,
lib/invoices/apply-recurring-schedule-update.ts, which snapshots the header
before writing it (only for a combined edit, the only case with something to
undo) and compensates it on any items failure. The rollback update is filtered
on the updated_at stamp our own write produced, so a concurrent writer (the
hourly cron, a second edit) wins instead of being clobbered from a stale
snapshot: audit finding C2 in lib/invoices/voucher-matching.ts.
A compensation that itself fails is no longer swallowed. The helper reports
itemsRestored / headerRestored, logs the unrecoverable rows and the intended
restore payload, and both call sites then return the new
INVOICE_RECURRING_UPDATE_PARTIAL registry entry, which tells the user in
Swedish that the schedule may be half-saved and to check fields and items
before retrying. A clean rollback keeps the PG-mapped error so a CHECK
violation still surfaces its specific message.
Also in the rewritten block:
- the items DELETE error is checked, so a failed delete no longer proceeds to
an insert that would duplicate every line;
- the 404 existence check moved above every write, so a PATCH with items for a
missing or cross-tenant id writes nothing;
- the items snapshot uses select('*') with id/created_at stripped on restore
(same idiom as replaceInvoiceItems), so a column added later is carried
through instead of silently dropped;
- NewRecurringScheduleDialog unwraps the nested { error: { message } } envelope
the route returns, which otherwise reached the toast as "[object Object]".
The cron's no-empty-items invariant holds on every failure path: the items are
either untouched, restored, or the failure is reported explicitly.
Fixes #1275
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(invoices): never write when the compensating snapshot is unavailable
Follow-up on the recurring-schedule rollback: the helper still performed two
writes it already knew it could not compensate.
- The header snapshot read now checks its error and a missing row, and the
header UPDATE is skipped entirely when either holds, so no header change is
committed that we already know can never be rolled back.
- An unreadable item snapshot now aborts BEFORE the delete (rolling the header
back) instead of deleting first and reporting itemsRestored: false, so the
cron invariant "a schedule always has items" holds on every failure path.
- That header read now runs whenever items are replaced and is scoped by
company_id, so it doubles as the ownership proof the schedule_id-only item
delete/insert lacks (the commit executor runs with RLS off). Stated in the
JSDoc as well.
- The item snapshot is paginated via fetchAllRows: a schedule with more than
1000 lines could otherwise restore partially while reporting a clean
rollback.
- The executor now returns errorCode INVOICE_RECURRING_UPDATE_PARTIAL,
surfaced as CommitResult.code and persisted as result_data.error_code, so a
staged-op caller can detect the partial state without substring-matching the
Swedish sentence.
- Route: details keys are camelCase throughout, and an item failure is logged
once, with the repair context kept on the partial path only.
Tests: the unreadable-snapshot branches are exercised (including the
previously unused itemsSnapshotError harness hook), and the test that pinned
"header written with no possibility of rollback" now asserts that nothing is
written at all.
Fixes #1275
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
392e847c1e |
fix(transactions): block invalid invoice match targets (#1294)
Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage. Fixes #1260 |
||
|
|
951b33363b |
feat(recurring): carry dimension bags on recurring invoice schedules (#1272)
Schedules and their template items now store {sie_dim_no: code} bags
(default_dimensions / dimensions), and the cron generator copies them
onto every spawned invoice + item, so recurring invoices book with the
same projekt/kostnadsstalle tags a manual invoice would. Wired through
the web CRUD routes, the staged-operation executors, and the MCP
create/update/list schedule tools (resolve-don't-select, resolutions
echoed in the preview).
Migration 20260728090000 adds the two jsonb columns (same shape+CHECK
as invoices/invoice_items, PR7 producer parity).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|