Commit Graph
10 Commits
Author SHA1 Message Date
MattssonandClaude Fable 5.1 d670fe6663 feat(invoices): named payee accounts and per-invoice choice of bank account (#2233)
* fix(enable-banking): read BBAN from AccountIdentification.other and store it on the account

Enable Banking has no top-level `bban` key on AccountIdentification: a
Swedish BBAN (clearing + account number) arrives as `other.identification`
with `other.scheme_name = 'BBAN'`, or in `all_account_ids`. The client typed
`bban?: string` and read `.bban`, so the value was always undefined: no
connected account ever carried its clearing + account number, and domestic
counterparty accounts on transactions were dropped.

Type the identifiers per the OpenAPI spec, add extractBban() and
pickAccountIdentifier(), read counterparty identifiers through the scheme
list (IBAN, then BBAN/BGNR/PGNR, then anything), and store `bban` on
StoredAccount from the OAuth callback. The external_id dedup scope stays
IBAN-then-uid and is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): named payee accounts on cash_accounts with a default per currency

A company had exactly one set of payment instructions per invoice currency
(company_settings.invoice_payment_accounts), picked by currency alone. A
second SEK bank account, or a second bankgiro number, had nowhere to live.

cash_accounts is already the per-company bank-account entity. Migration
20260903150000 adds the payee fields (bankgiro, plusgiro, clearing +
account number, BBAN, BIC, Swish, foreign routing) plus invoice_payee, a
small invoice_payee_defaults table (one default account per currency; one
account may be the default for several currencies, a SEK account with an
IBAN is the usual EUR payee), and a SECURITY DEFINER mirror that rewrites
the legacy map and the SEK bank columns from the default accounts. Every
existing reader (PDF, email, reminders, v1, MCP) keeps working; the three
writers that only touched legacy columns (PUT /api/settings, v1 settings,
MCP update_company_settings) now write through to the default account, so
what an agent sets is what the PDF prints. Peppol PaymentMeans is built
from the resolver instead of the raw legacy column. bg_pg is dropped
(never read or written; NULL on every prod and staging row).

Backfill lands only on existing cash accounts (primary, IBAN match, or the
only enabled account in the currency). Entries with no target stay in the
map as the resolver fallback and get an attach action in settings.

New: POST /api/cash-accounts (manual bank account on the next free 19xx),
PATCH /api/cash-accounts/[id] payee fields (owner/admin), GET/PUT
/api/cash-accounts/payee-defaults. Settings page rewritten as an account
list with per-currency defaults. Behandlingshistorik and the full archive
cover the new table and columns.

Verified on staging: migration applied (11 defaults landed), mirror
trigger observed rewriting company_settings from a payee edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): choose which bank account an invoice is paid to, frozen at issue

Migration 20260903160000 adds invoices.payment_cash_account_id (FK to
cash_accounts, SET NULL) and invoices.payment_details, the payee fields
frozen when the account is chosen and refreshed at issue.

Resolver: resolveInvoicePaymentAccount / companyWithInvoicePaymentAccount /
assertInvoicePaymentAccountForRender take an optional override, and
hasRequiredInvoicePaymentAccount reads it from the invoice row, so every
surface (PDF, Swish QR, email, reminders, payment confirmation, Peppol,
recurring, staged MCP send) prints the frozen payee when one exists and the
company default per currency otherwise. Invoices that never chose an
account behave exactly as before.

Issue paths (mark-sent, send, v1 send, v1 mark-sent, Peppol send,
recurring, MCP send and mark-sent) refresh the snapshot from the account as
it is at issue; a chosen account that is disabled, un-flagged or unusable
for the currency blocks with INVOICE_SEND_PAYMENT_ACCOUNT_INVALID.

Writers: dashboard POST/PATCH, v1 create/update and MCP create_invoice
accept payment_cash_account_id and validate it against the company's payee
accounts (INVOICE_PAYEE_ACCOUNT_INVALID). Credit notes inherit the
original's payee; copies carry the choice; preview-pdf renders the chosen
account. The editor shows "Betalas till" under the currency when the
company has two or more usable payee accounts for that currency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* feat(invoices): book manual payments on the invoice's chosen bank account

Manual mark-paid (dashboard, v1, MCP gnubok_mark_invoice_as_paid) and the
booking dialog's proposed lines debited 1930 regardless of which bank
account the invoice asked to be paid to. They now resolve the chosen
payee account's ledger account (resolveInvoiceSettlementAccount) and fall
back to 1930 only when no account was chosen or the row is gone.

Bank-transaction matching keeps debiting the account the money landed on
and does not filter by the chosen account; between equal-confidence
candidates it prefers the invoice that asked to be paid to the landing
account. Scores are untouched, so nothing new auto-matches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* chore(invoices): keep the payload-size and phantom-column ceilings after the payee work

Shorten the new gnubok_create_invoice argument description (tools/list
payload was 29 bytes over the 60 kB budget), inline the cash-account payee
UPDATE/INSERT payloads and the settings select strings as literals so the
phantom-column scanner can read their columns, and reuse ACCOUNT_NUMBER_RE
instead of a hand-rolled copy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): harden the payee model after review (admin-only payee columns, separate payee IBAN, company-scoped FK)

Review findings from CodeRabbit, Superagent, the Swedish accounting review
and three skeptic passes, resolved in one batch:

Schema (both migrations are unshipped and edited in place):
- cash_accounts.payee_iban: the printed IBAN is its own column. iban stays
  the bank identity written by every sync and used to re-pair on reconnect,
  so a sync can no longer rewrite an invoice instruction or resurrect a
  cleared IBAN. The backfill copies each currency entry verbatim onto the
  target account (IBAN match first, then primary), so every invoice keeps
  printing exactly what it printed before; the bank IBAN is never pushed
  onto invoices that did not carry one.
- Payee columns are owner/admin-only at the database (BEFORE trigger,
  service role exempt): cash_accounts is member-writable for bank sync, and
  the SECURITY DEFINER mirror would otherwise have let a member rewrite
  where customers pay.
- Revoking an account as payee or disabling it drops its defaults; deleting
  a default drops that currency from the map and clears the legacy SEK
  columns (an admin saying "nothing to print" must not keep printing a
  closed account). The mirror leaves the legacy SEK columns alone when the
  map has no SEK entry, so legacy-only companies are never wiped by a
  mirror run for another currency.
- Audit and mirror triggers fire on the same column set; anon and
  authenticated can no longer execute the trigger-only definer functions.
- invoices.payment_cash_account_id is a composite same-company FK with
  SET NULL scoped to the account column.

Code:
- Only 19xx bank accounts can be payee: PATCH, the defaults PUT (which now
  also requires enabled, payee-flagged and usable for the currency),
  resolveInvoicePayeeChoice, and the mark-paid settlement resolver (which
  also refuses disabled rows and logs every fallback to 1930).
- createManualBankAccount excludes every ledger slot any row already holds
  (findFreeLedgerAccount treats a manual holder as free; this path inserts).
- The legacy settings writers (PUT /api/settings, v1, MCP) write through to
  the account BEFORE updating company_settings and fail the request on
  error; the account is written before it is adopted as default so the
  mirror never sees an empty payee.
- snapshotInvoicePayee: dry runs no longer persist; a failed snapshot write
  blocks issue (INVOICE_PAYEE_SNAPSHOT_FAILED). v1 mark-sent/mark-paid
  projections carry the payee columns; v1 create validates the payee
  before the dry-run return and echoes it in the preview.
- pickAccountIdentifier: supplementary IBAN wins over a primary BBAN, and
  non-account schemes (card PANs) are never persisted.
- Editor shows the payee select for a single usable account with no
  default; the booking dialog waits for cash accounts before proposing
  lines; a failed default write no longer hides a created account.
- Behandlingshistorik names the account on created/deleted defaults.
- Regenerated skills/accounted-api; MCP argument description trimmed under
  the tools/list payload ceiling.

Declined: clearing legacy columns via a forward migration (the mirror now
does it on delete); Swedish review's "show the debit account in the
mark-paid UI" (the booking dialog already proposes and lets the user edit
the debit line); manual ledger collision (UNIQUE exists, and the create
path now rejects it with a clear error); Peppol aligning to the PDF value
for companies whose legacy column had drifted from the map (the PDF is the
customer-facing document; both now agree).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): read NEW.invoice_payee only on the cash_accounts branch of the mirror trigger

trg_mirror_invoice_payee_defaults fires for both tables; plpgsql resolves
record fields per expression, so the combined condition failed with
"record new has no field invoice_payee" whenever a default row changed,
which took down every pg-real case on the payee tables. The revoke/disable
check now sits inside its own TG_TABLE_NAME branch. The MCP settings
executor test mocks the payee write-through like the settings route test
already does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): keep member disables from revoking payee defaults, gate payee on 1920-1999, fit the MCP payload

Cycle 3 of /resolve-pr on #2233.

Superagent P1: the SECURITY DEFINER mirror trigger deleted an admin's
invoice_payee_defaults rows whenever cash_accounts.enabled flipped to
false, and enabled is member-writable (the bank picker's "Synkas ej"), so
a member could undo an admin's payee decision. The trigger now drops
defaults only on the admin-only invoice_payee true -> false revoke; the
mirror trigger's WHEN no longer lists enabled. Disabled accounts stay out
of the pick lists and the send gate already refuses an invoice that chose
one. Applied to staging as the same function + trigger definition and
probed inside a rolled-back block: disable keeps the default and the
mirrored bankgiro, revoke clears both.

pg-real: the admin-guard test ran three expectations inside one
withUserContext transaction; the first raise aborted it and the next
statement failed with "current transaction is aborted". One transaction
per expectation now, and the member case also flips enabled to prove the
column stays member-level.

Swedish review: payee eligibility was /^19\d\d$/, which admits 1910 Kassa
and the 1911-1919 tills. A customer pays to a giro or bank account, so
isBankCashAccount, CreateCashAccountSchema.ledger_account and the PATCH
route now require BAS 1920-1999; tests cover 1910 and 1919.

Unit tests (3/4): the tools/list payload guard read 60 025, then 60 014
tokens after main merged #2166 and #2163 alongside this branch. The
ceiling is not bumped and no read on this surface is a demotion
candidate, so gnubok_create_invoice drops payment_cash_account_id;
agent-created invoices print the per-currency default and v1 REST plus
the editor keep the field. Recorded in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* chore(migrations): move invoices_payment_cash_account to 20260903183000 after colliding with main's KPI migration

origin/main merged 20260903160000_kpi_monthly_include_reversed_originals
while this branch held the same version; identical versions abort the
Supabase apply. Staging's schema_migrations row was moved to the new
version with the file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaZTY21HVN57hJoPXKSLjV

* fix(invoices): gate invoice_payee on BAS 1920-1999 at the database, and unblock the typecheck ratchet

Cycle 4 of /resolve-pr on #2233, on Emil's go.

Swedish review: the 1920-1999 payee rule lived only in the routes. The
cash_accounts_payee_admin_only trigger now also refuses invoice_payee on
any other ledger (INVOICE_PAYEE_ACCOUNT_INVALID, 23514), whoever writes
it, and the backfill only targets giro/bank rows, so a company whose
single enabled cash_accounts row is a Stripe clearing account keeps its
legacy bankgiro in company_settings instead of landing it on 1686. pg
test covers insert and update on 1686 and 1910; the function was applied
to staging and probed.

Typecheck ratchet: main is red from two merges that landed with failing
Checks, and every branch that syncs it inherits the errors.
  - #2242 added POST(req) calls to the fiscal-periods route test without
    the route params argument withRouteContext handlers take (25 errors
    in the file, baseline 23). All 25 calls now pass
    createMockRouteParams({}).
  - #2247 made SyncResult.requestedFromDate and historyNarrowed required;
    the 13 mockedSync results in the enable-banking accounts-route test
    lacked them. They now carry a fixed date and historyNarrowed: false.
Both files' tests pass unchanged in behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(migrations): move invoices_payment_cash_account to 20260903193000 after colliding with main's party_promotion

origin/main merged 20260903183000_party_promotion while this branch held
the same version. Staging's schema_migrations row must follow (pending:
the Supabase MCP was disconnected at the time of this commit).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:06:45 +02:00
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>
2026-09-02 11:38:30 +02:00
MattssonandClaude Fable 5 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>
2026-08-18 16:39:49 +02:00
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries

* test: cover annual report depreciation and VAT balances

* Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch

* fix: show exact invoice delivery details

* fix: use currency account in invoice emails

* fix: address invoice delivery review feedback

* fix: harden invoice delivery and payment accounts

* test: assert RLS-denied zero-row updates

* fix: close remaining invoice compliance gaps

* fix: harden invoice archive authorization

* fix: close invoice delivery review findings

* fix: verify delivery finalization results

* fix: cap combined invoice email recipients

* fix: close final invoice compliance findings

* fix: prevent stale payment account saves

* test: prove invoice delivery isolation

* fix: close invoice privacy review findings

* test: normalize delivery retention dates
2026-07-23 09:54:02 +02:00
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00
MattssonandClaude Fable 5 7d7f604e00 Add/stripe invoice link (#998)
* feat(supplier-invoices): show registered invoices under "Att betala" with inline approve

Registered supplier invoices are already booked as debt (2440) but were
hidden from the "Att betala" tab until approved, which confused users.
The tab now shows registered invoices too, marked "Ej godkand" with a
compact inline approve button. Approval remains the gate for payment,
not visibility; status model and approve API untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reports): add date range filter to huvudbok (kontoanalys)

Mounts the existing ReportDateRange control on /reports/huvudbok so the
ledger can be narrowed to any date range within the fiscal year, matching
Fortnox kontoanalys. Lines before the range roll into each account's
opening balance so running balances stay correct at the range start;
lines after the range are dropped. Applies to the XLSX export too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(invoices): add optional payment link on invoices (paste-link MVP)

The user pastes a payment link created in their PSP dashboard (e.g. a
Stripe Payment Link) onto an invoice. The recipient gets a "Betala
online" button in the invoice email and a QR code + clickable link in
the PDF payment box. No PSP integration server-side: this is the
demand probe; a future Stripe Connect integration would auto-fill the
same column.

- invoices.payment_link_url (migration 20260709090000), https-only +
  2048-char cap enforced in CreateInvoiceSchema; empty string
  normalises to undefined and build-invoice-write always writes a
  concrete value so clearing the field on a draft edit NULLs the column
- editor field (real invoices only) with one-link-per-invoice hint;
  strings in sv+en (messages landed via e0e11066)
- email button (customer.language, hidden for credit notes/proforma/
  delivery notes, URL escaped for the href attribute) + URL in the
  plain-text part
- PDF QR + link row following the Swish QR pattern; wired into send,
  download and preview routes
- derived documents (credit note, proforma convert, recurring) do NOT
  copy the link: it encodes one amount for one specific invoice
- MCP gnubok_create_invoice accepts payment_link_url (validated at
  staging and re-checked in the commit executor); v1 API exposes the
  column; tools/list token ceiling bumped 45K -> 45.5K (ledger entry
  in payload-size.bench.test.ts, headroom was <10 tokens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): show oresavrundning on editor/form totals, supplier list and invoice email

The rounding logic (getDisplayTotal) was correct but only applied on the
PDF, invoice list/detail and review dialog. The invoice editor summary,
the supplier invoice form totals and the supplier invoice list showed the
raw ore total right next to the toggle, and the invoice email said
"Att betala" with the unrounded invoice.total while the attached PDF
showed the rounded amount (and the email also ignored the ROT/RUT
deduction).

Extract the PDF's Att betala block into getAmountToPay
(lib/invoices/rounding.ts) and point PDF + email at it so they cannot
drift; behavior-identical refactor for the PDF. Booked amounts stay
ore-exact; display-only as designed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reports): adapt huvudbok date-range tests to the two-step entry-lines fetch

The date-range tests (0969168f) mocked the old single-query shape with the
parent entry embedded on each line; main's refactor (fetchEntryLines)
queries journal_entries first and reattaches. Queue entry rows like the
other tests so the merge of the two features is actually exercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): fetch full invoice projection in v1 send so ROT/RUT deduction and payment link reach the PDF and email

The v1 send route's hand-rolled column list omitted deduction_total,
deduction_personnummer_last4, payment_link_url and the item-level
ROT/RUT fields, so invoices sent via the public API overstated
'Att betala' and dropped the deduction box. Reuse the shared
INVOICE_FULL_COLUMNS/INVOICE_ITEM_FULL_COLUMNS so the send row can
never drift from the GET shape again.

Also harden the supplier-invoice inline approve: a thrown fetch left
the button stuck spinning; failures now refetch the true server state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 00:56:16 +02:00
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00
Jakob WennbergandClaude Opus 4.8 10a0b1d8dd fix(invoices): embed company logo as PNG so it renders on invoice PDFs (#772) (#776)
* fix(invoices): embed company logo as PNG so it renders on invoice PDFs (#772)

@react-pdf/renderer's <Image> only decodes JPG/PNG, but the logo upload route
and the `logos` bucket also accept SVG and WebP. For an SVG/WebP logo @react-pdf
silently swallows the decode error (console.warn inside a try/catch in its
fetchImage step), so the invoice renders with NO logo and nothing surfaces —
"Logotyp kommer inte med på fakturor".

Fix: prepareInvoicePdfRender now fetches the stored logo and re-encodes it to a
PNG data URL via sharp (SVGs rasterized at higher density), handing the template
a company whose logo_url is that data URL. Renders regardless of upload format
and removes the render-time dependency on a remote fetch inside @react-pdf.
Falls back to the original URL unchanged on any failure (network, unreadable
image, sharp unavailable), so behaviour is never worse than before. Result is
cached per logo URL (5-min TTL, bounded to 50) since the logo is re-rendered on
every invoice — twice per send and once per invoice in recurring/batch loops.

prepareInvoicePdfRender becomes async and returns the resolved { branding,
company }; all 8 call sites updated (6 routes, recurring-schedule-service,
pending-operations/commit) to await it and pass the resolved company. Layered
cleanly on top of the Swish-QR feature already on main — both coexist at every
call site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoices): bound and dedupe the logo fetch (review hardening)

Review (PR Agent security): resolveLogoDataUrl fetched logo_url with no timeout
or size limit. Add a 5s AbortSignal.timeout and a 5 MB cap (checked on the
declared content-length and the read body) so a slow/oversized logo host can't
hang or balloon an invoice render. SSRF itself isn't reachable today — logo_url
is only ever set to a Supabase logos-bucket URL by the upload route — so an
origin allowlist is intentionally skipped (would break self-hosted storage).

Also coalesce concurrent renders of the same logo (preflight+final on a send,
and recurring/batch loops) onto one in-flight fetch+encode instead of N. New
test covers the size-cap fallback; existing SVG test now asserts the timeout
signal. 9/9 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:42:53 +02:00
MattssonandClaude Opus 4.8 241959513b Fix/mcp and req (#753)
* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:49:33 +02:00
MattssonandClaude Opus 4.7 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00