* fix(enable-banking): keep bank account mappings across reconnects and surface dead sessions A PSD2 reconnect silently moved the user's ledger mapping. Account identity came from the provider's account uid, which does not survive a re-authorization at every ASPSP, and a fresh connect to an already-connected bank mints a new bank_connections row regardless. Both paths looked like "an account we have never seen", so the allocator handed out the next free 19xx slot and a 1930/1940/1941 mapping came back as 1942-1946 on every consent renewal, roughly quarterly per connection. Match on the IBAN instead. resolvePsd2LedgerAccount() finds the existing cash_accounts row by normalized IBAN before allocating, and upsertFromPsd2 promotes that row in place rather than inserting a second one, so it keeps its id and its linked transactions and is re-pointed at the connection that just authorized. The previous holder's connection status is deliberately ignored: one IBAN is one physical account, and the old row often still reads 'active' because the bank killed the session without telling us. The allocator also stopped treating a 19xx number as free just because no cash_accounts row holds it. A chart imported from SIE carries the company's real bank accounts by name with no PSD2 row behind them, which is how a SEK company account got proposed as an unrelated brokerage account. Overflow now skips chart-occupied numbers, falling back only when nothing unnamed is left. Dead connections kept rendering as "Aktiv": status only ever changed when a transaction fetch failed, so a session killed bank-side stayed healthy-looking with a stale last_synced_at while the user read old balances as current. Add probeSessionHealth() and run it in the daily cron over every connection that run did not prove alive, including the ones the loop skips silently (capability gate, all accounts deselected) and the ones parked in pending_selection that the cron never looked at. It acts only on a definite dead answer; anything ambiguous leaves the row alone, since a wrong flip costs a full BankID re-authorization. The all-accounts-deselected branch is reclassified 'synced' to 'skipped' for the same reason: it never contacts the bank, so it must not count as proof of life. The settings row warns when an active connection has not synced in three days or has never synced. Which company a connection belongs to was invisible. Everything was already scoped to ctx.companyId, so there was no cross-tenant leak, but a bank authorized while the wrong company was active looked identical to the right one. Name the company on the connect surface and in the account picker, and say where the connection went when the callback lands under a different active company. Warn (bypassably) before authorizing a bank where the same user already holds live connections in other companies: several ASPSPs allow one active AIS session per login, so the new authorization can kill the others. The history start date already defaulted to the fiscal-year start; the card above it recommended a mid-year date and contradicted the selected option. It now states the fact and offers the shortcut without presenting it as advice. Not addressed: sharing one PSD2 session across companies. company_id is the tenancy anchor on bank_connections and cash_accounts hangs off (company_id, bank_connection_id), so that needs the session to become its own entity. See DECISIONS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): show the posted line description in the voucher preview The "Verifikation som bokförs" preview built its expense debit lines with description set to the raw account number, so the BESKRIVNING column showed "5615" or "6990" where the posted verifikat actually says "Leverantörsfaktura 123, ACME AB". A hardcoded 11-entry ACCOUNT_LABELS map masked this for 2440/2641/26xx, which is why the column read as a mix of friendly labels and bare account numbers, neither of which was the posted text. The preview now renders exactly the line_description the engine writes: the shared invoice-level text on expense lines and 2440, "Ingående moms {rate}% {desc}" on 2641, and the reverse-charge pair taken straight from generateReverseChargeLines instead of being re-derived locally. buildSupplierDescription moves into its own dependency-free module so the client-side preview can call it without pulling the journal engine (and its Supabase server client) into the browser bundle. The account name stays reachable on the AccountNumber hover card. Picked option A from the issue, keeping the fixed invoice-level description rather than propagating each item's own text: the customer-invoice side already writes invoice-level descriptions, so per-item text would create an inconsistency between the two invoice sides rather than remove one, and it would need an aggregation-collision policy in the journal engine. Rationale recorded in DECISIONS.md. Refs #1258 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bookkeeping): restore the copy icon on verifikat rows The row-language rewrite in #1123 reused the copy icon's slot for the new expand toggle, removing the zero-click copy affordance from the bookkeeping list without mentioning it. The leftover orphaned copy_voucher_tooltip key in both message files is what identifies it as collateral rather than a product decision. Restore a copy icon in the row's right-edge action cell, reusing that key for aria-label and title. stopPropagation keeps the click off the row's expand toggle. The icon is hover-revealed on md+ and always visible below it: #1123 collapsed the desktop table and the mobile card into one responsive table, so hover-only would leave touch users with nothing. Copy is no longer gated on posted. The copy_from handler and the GET journal-entries route never looked at status, so copying a draft already worked end-to-end and only the detail-page button hid it; the two list surfaces were already ungated. Both list affordances now respect canWrite, which previously dropped read-only users into a dialog they could not submit. The repo does not render components in tests, which is why #1123 removed this silently. Pin the source shape instead, the same way the copy-invoice query is pinned. Closes #1266 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(transactions): revalidate stale invoice match pointers before offering a match potential_invoice_id / potential_supplier_invoice_id are written once, at bank import, and never revisited. When one of several identical recurring invoices was settled by a different transaction, every other transaction kept pointing at the now fully paid invoice. The match dialog then measured the bank amount against a 0 kr remaining balance and reported a "Beloppen skiljer sig ... fakturan blir delbetald" partial payment, and the worklist offered the same dead suggestion as a one-click confirm row. Worse, the manual escape hatch was hidden exactly when it was needed: TransactionInboxCard only shows "Matcha mot leverantörsfaktura" when no suggestion exists, so a stale pointer left the user with no way at all to reach the correct invoice. Fixed by revalidating at read time rather than by clearing sibling pointers on settle. Invoices are settled through many paths (both match routes, mark-paid, MCP, bank reconciliation, SIE import), so write-time cleanup leaks the moment one is missed, while the candidate lookup covers every route into the list. The shared accept-lists in lib/invoices/matchable-statuses.ts mirror the CAS guards the match routes already enforce. - listSuggestedMatches and the transactions page candidate fetch filter on status + remaining_amount, so a settled candidate yields no suggestion and the manual picker reappears on its own. - InvoiceMatchDialog blocks a settled target with a distinct message and a disabled confirm. Not advisory: both routes reject it outright with MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID, so no override could succeed. - The supplier detail card now shows remaining_amount like the customer branch, instead of total. On a partially paid invoice it used to print "1 250 kr" directly beside "Differens: 1 250 kr". - match-supplier-invoice clears potential_supplier_invoice_id on the transaction it just matched, mirroring the customer route. No bookkeeping was ever at risk: both routes already refused a settled target before creating a voucher. The damage was confined to a misleading dialog and a dead end. createQueuedMockSupabase gains passive call recording (calls / findCall / findCalls) because the proxy swallowed filter and update arguments, which made the new assertions inexpressible. Refs #1259, #1260 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(webhooks): dispatch on emit instead of waiting for the next cron tick (#1256) * feat(webhooks): dispatch on emit instead of waiting for the next cron tick The webhook dispatcher ran only on a per-minute cron, so the floor on delivery latency was up to 60 seconds plus the request. An external consumer that wanted to react as a transaction landed had only one alternative: polling /api/events, which the 100 rpm per-key limit makes expensive and which still cannot beat the tick interval. Schedules one dispatch cycle as soon as deliveries are enqueued. The cron is unchanged and remains the retry and sweep path; this only moves the first attempt forward. Wired into the event-bus fanout plus the two routes that enqueue a delivery directly: the :test verb, whose entire purpose is telling someone whether their receiver works, and the manual delivery retry. Three properties are load-bearing and covered by tests. The kick is never awaited, because eventBus.emit is awaited at ~99 call sites including journal_entry.committed and each delivery can burn a 10 s receiver timeout. It coalesces per function instance, so a bulk booking that emits once per row does not schedule one claim round trip per row. It claims 5 rows rather than the cron's 50, because it runs on the tail of a user-facing request. Double delivery is not a risk: claim_due_webhook_deliveries already claims FOR UPDATE SKIP LOCKED and flips rows to in_flight in the same statement, so a kick racing the cron sees disjoint rows. Does not close #1201, which asks for a realtime stream for API consumers. This is the cheap half. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(webhooks): stop claiming the kick makes double delivery impossible Adversarial review of the previous commit caught an overstatement in its own comments. SKIP LOCKED keeps a kick and the cron from claiming the same row at the same moment, but claim_due_webhook_deliveries autocommits before any POST is issued, so from then on ownership is only status='in_flight' and a later cycle's recoverStuckInFlight sweep can re-arm a row still queued behind an earlier cycle's serial loop. Delivery is at-least-once, which is what the public docs already tell receivers ("the same delivery id may arrive more than once ... idempotency is on you"). The comments contradicted that. No behaviour change. The kick does not create this window: the cron claims 50 rows serially against the same 20 s stuck threshold, which is wider than what a batch of 5 can open. Refs #1201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base (#1253) * fix(bokslut): add bokslut-flow depreciation (78xx) back to the bolagsskatt base sumPostedYearEndDispositions reconstructs resultat fore skatt for the tax calculation, because generateIncomeStatement excludes every source_type='year_end' entry. It summed class 88 and 7533 but not 78xx, so planenlig avskrivning posted by the bokslut flow (lib/bokslut/assets/depreciation-engine.ts) was dropped from the income statement and never added back. The bolagsskatt base and the periodiseringsfond 25 % cap were therefore computed on an overstated result: tax too high by roughly 20.6 % of the depreciation. Also exclude the period's final bokslutsverifikation from the fetch. It carries source_type='year_end' as well and reverses every P&L account, 78xx/88xx/7533 included (verified against production closing entries), so once the year is closed it would cancel the add-back this function exists to produce. That hazard already applied to 88xx and 7533; the fix closes it for all three rather than widening it. Scope is deliberately the tax base only. Making the standalone resultatrakning show bokslut entries is a separate, larger change: the same exclusion is duplicated in the kpi_report_aggregates RPC, it moves displayed profit for every company that ran the bokslut flow, and it means removing the add-back at four call sites. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(bokslut): scope the closing-entry lookup to the company and fail loudly Review (CodeRabbit + the compliance swarm, ASVS V8.2.1) flagged the new fiscal_periods read in sumPostedYearEndDispositions on two counts, both fair. It filtered only on the period id while every sibling query in the same function carries the tenant scope. Primary key or not, service-role paths have no RLS to fall back on and the repo's rule is to filter company_id explicitly, so it now does. It also discarded the query error. That mattered more than it looks: a failed read fell through to closingEntryId = null, which silently re-admits the closing verifikat's 78xx/88xx reversals and understates the tax base, i.e. exactly the failure this lookup was added to prevent. It now throws, and the surrounding catch turns it into the existing 'Failed to read posted dispositions' error. A wrong bolagsskatt is worse than a loud failure. Two regression tests: the lookup carries both eq filters, and a lookup failure propagates instead of degrading to a wrong number. Refs #1051 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(storage): drop the client-side DELETE policy on the documents bucket (#1254) * fix(storage): drop the client-side DELETE policy on the documents bucket 20240101000024 documents this bucket as WORM: "No UPDATE or DELETE policies". That described the repo, not production. Production carries a users_delete_own_documents policy that exists in no migration file: FOR DELETE TO authenticated USING (bucket_id = 'documents' AND (storage.foldername(name))[2] = auth.uid()::text) Under it, the uploading user can delete the storage bytes of any document they uploaded under the legacy documents/{userId}/... layout, using nothing but their normal browser token. That includes documents linked to a posted verifikat, which are rakenskapsinformation under the BFL 7 kap 2 § seven-year retention duty. deleteDocument()'s linked-check and the block_document_deletion() trigger both guard the document_attachments ROW, not the object: the row survives, still pointing at a file that is gone. Reproduced against a local replay of the full migration stream: with the policy present the uploader's own DELETE removes the object; with it dropped the same statement matches zero rows. Company-scoped keys were never exposed (their second path segment is the company id, not auth.uid()), so this only ever reached the legacy layout, which is where most documents still live. Safe because every in-app remove() on this bucket already runs on the service role, covered by service_role_all_documents. Deliberately narrow: users_read_own_documents and users_upload_own_documents stay. The Phase B backfill from 20260726092000 has not run, so dropping the legacy SELECT policy now would make existing documents unreadable. That is Phase C. The pg-real test asserts no DELETE and no UPDATE policy over the bucket under ANY name: the hole arrived under a name this repo never used, so pinning a name would not have caught it. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): make the WORM ratchet see FOR ALL and WITH CHECK policies Review caught two blind spots in the ratchet, both fair. It matched only polcmd 'd' and 'w', but polcmd '*' (FOR ALL) grants DELETE and UPDATE just as effectively, and FOR ALL is the shape the one legitimate policy on this table already uses, so a hostile one would look unremarkable in the catalogue. It also read only polqual, so an UPDATE policy carrying its bucket restriction in WITH CHECK was invisible. Both assertions now run through one helper that covers d/w/*, concatenates USING and WITH CHECK, and filters by grantee so service_role_all_documents (how the application does its authorized deletes) is excluded while every client-reachable role is not. A policy granted to PUBLIC has an empty polroles, which is the most permissive case there is, so it is treated as client-reachable rather than as "no roles". Matching on the substring rather than the exact `bucket_id = 'documents'` shape pg_get_expr emits today: a policy written as bucket_id::text or with the comparison reversed would slip past a stricter match, and for a WORM ratchet a false alarm is cheap while a silent hole is not. Adds a probe case that creates a FOR ALL policy and asserts the helper sees it, so the main assertion cannot pass vacuously. That case earned its keep immediately: it caught that node-postgres hands back a raw string for a name[] column, so the role filter needed rolname::text to work at all. Verified against a local replay of the full migration stream: red with the original prod FOR DELETE policy present, red with a FOR ALL probe, green without either. Full pg-real suite 933 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(storage): catch a destructive policy that names no bucket at all Adversarial review of the previous commit found the ratchet still failed open, and reproduced it: a policy with no bucket_id predicate covers EVERY bucket, documents included, so gating on the bucket name discarded exactly the widest hole. The concrete shape is Supabase's own stock "Enable delete for users based on user_id" template, USING (auth.uid() = owner), which is the single most likely form of a future dashboard edit. A destructive policy is now in scope unless it provably cannot reach this bucket, i.e. only a bucket_id predicate naming some other bucket exempts it. The behavioural assertions had the matching blind spot: fixtures were seeded without an owner, so an owner-based policy matched NULL and the DELETE reported 0 rows for the wrong reason. Objects now carry an owner the way storage-api stamps them in production, so those tests fail loudly instead of passing by accident. Two probes pin both directions: a bucketless policy must be reported (and is shown to really permit the delete), and a policy scoped to another bucket must not be, so the ratchet cannot start crying wolf on receipts or sie-files and get switched off. Verified against a local replay of the full migration stream: red with the stock bucketless template installed, green without it. Full pg-real suite 935 passed. Refs #1208 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(kontoplan): make a deactivated account reachable again (#1262) is_active=false read as "does not exist" on every read path but as "exists" on the (company_id, account_number) unique constraint, so a deactivated account vanished from the kontoplan with no way back and re-creating it answered "Kontonummer X finns redan i din kontoplan." The write side was already correct: POST /accounts/activate has a toReactivate branch and PUT /accounts/[number] accepts is_active:true. Both were simply unreachable, so this opens routes to them rather than relaxing the read filters, which are load-bearing for AccountsNotInChartError. - Kontoplan gets a "Visa inaktiva" filter; inactive rows carry an "Inaktiv" chip and the existing per-row switch reactivates them in one click. - Deactivating an account that has posted lines now warns first, using the usage count already loaded for the Verifikat column. - POST /accounts distinguishes the two collisions and returns the new ACCOUNT_EXISTS_INACTIVE code; AddAccountDialog offers "Aktivera kontot istallet" rather than a dead-end 409. The stored account is left exactly as it was; values typed into the failed create form are not applied. - bas-lookup consults the company's own chart before the static BAS reference, so a deactivated custom account reads as known and "Aktivera och bokfor" is no longer disabled for it. New in_chart / is_active fields let callers tell "will be added" from "will be revived". - BAS-katalog stops showing "Aktiverat" for an account the company holds but has deactivated; it falls through to a relabelled Aktivera button, and the per-class counts follow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off (#1255) * fix(supplier-invoices): flag foreign 0 % lines with reverse charge switched off A foreign supplier charging no Swedish VAT is normally omvand skattskyldighet. With the reverse-charge switch off, createSupplierInvoiceRegistrationEntry emits neither the 26x4 output leg nor the 44xx/45xx basis lines, so ruta 20-24, 30-32 and 48 all stay empty and the momsdeklaration takes a shape Skatteverket rejects. For a fully deductible purchase the net moms att betala is unchanged, which is exactly why this goes unnoticed. The form already auto-ticks reverse charge for eu_business but not for non_eu_business, so that path slips through silently. Adds a pure helper plus a non-blocking banner cloned from the existing rc_account_warning block. Deliberately silent for swedish_business, where 0 % is a genuine exemption that belongs in no ruta at all, and phrased as a question rather than an assertion: a non-EU goods purchase cleared at customs is legitimately 0 % without reverse charge, and pushing that user into ticking the switch would manufacture a new wrong verifikat. Does not add the exempt/import/other picker the issue proposes: supplier_invoices.vat_treatment is metadata that no booking or ruta mapping reads, and the codebase cannot book import VAT at all, so an import option would imply ruta 50/60 were handled when they are not. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): name the local-VAT case in the foreign 0 % hint Review flagged that the most common foreign document a Swedish small company sees is an invoice carrying the supplier's OWN local VAT, booked at 0 % Swedish VAT with reverse charge correctly off. The banner fires there, and the previous copy only offered "momsfri av annat skal, till exempel en varuimport" as the way out, which does not describe that invoice at all: it is not VAT-free, it carries foreign VAT. Names both legitimate cases explicitly and says 0 % is correct in them, so the hint cannot read as an instruction to tick reverse charge on a purchase where that would produce a wrong verifikat. Title also narrowed to "utan svensk moms" for the same reason. Refs #1042 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sandbox): call the sandbox assistant Assistenten, not Anna (#1244) A named persona earns its name once someone has been through onboarding and chosen it: it is their assistant and they named it. Nobody in the sandbox chose anything, so a first name reads as a character the product invented and implies a relationship the visitor never opted into. Both halves move together, which is the point. profile_summary is the agent's own self-description inside the system prompt, so leaving it as "Du är Anna" would have the header say one thing while the assistant introduces itself as another in its first sentence. Nothing else in the stack checks that pairing, so a test now does. Scope: this changes the seed, so new sandbox companies get the new name. The 483 sandbox profiles already seeded keep 'Anna' (the seeder returns early once a profile exists, and its caller only runs while verified_at is null). Backfilling those is a production write on demo data and is being raised separately rather than smuggled into a code change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> * feat(reports): show the last posted voucher per series in report headers Adds a "Senaste bokforda verifikat: A 214, B 37" line to the balans- and resultatrapport, so a printed or exported report answers which vouchers are actually in it rather than only which dates it spans (#1267). Reads MAX(voucher_number) over posted entries, never voucher_sequences.last_number. The sequence counter is an allocation high-water mark that drifts from the books in both directions: next_voucher_number burns a number when the follow-up insert fails, delete_last_voucher decrements by one instead of resetting to the new MAX, and pre-RPC SIE imports left it behind. Since the point of the line is avstamning, an allocated number would send a reconciler chasing a gap that does not exist, so the label says plainly that the number is the posted one. Scoped to the report own date range, so a Q1 report printed in November says something true about Q1. The balansrapport keeps the fiscal-year start as its lower bound because it accumulates. Skipped on a dimension-filtered resultatrapport: that report already discloses it is partial, and an unfiltered voucher range beside a filtered result invites the wrong conclusion. Populated in both engines, so the JSON, PDF and XLSX routes all inherit it without signature changes. Best-effort: a header nicety never breaks a report. The pure formatter lives in its own module so the client view does not pull the Supabase query path into the browser bundle. No new i18n keys; both report views and the PDF template are hard-coded Swedish per the "stays Swedish" report surfaces in .claude/rules/i18n.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(customers): stop rendering personnummer ciphertext, make unreadable rows editable, add a reveal path (#1263) customers.personal_number holds AES-256-GCM ciphertext (20260726110000). Three defects compounded into one broken surface for private customers. The list queried Supabase from the browser with select('*') and rendered the raw value, 76-82 chars of hex, into the nowrap identifier cell. It now reads GET /api/customers, which already masks every row, so the ciphertext never leaves the server. Searching by personnummer works again: the client filter had been matching against ciphertext and could never hit. A row whose value cannot be decrypted renders as the placeholder '********-????'. None of the three mask checks recognised it, each having its own '-1234'-only copy, so such a customer could not be edited in ANY field: name and address edits 400'd on a personnummer the user had no way to correct. All three now share one pattern from the new crypto-free lib/customers/mask-personal-number.ts, which the client form can import. Typing a fresh personnummer overwrites the unreadable value, which is the only repair possible: the rejected writes failed whole INSERTs, so there is nothing to backfill. The value was write-only by construction. GET /api/customers/{id}/personal-number is the deliberate drill-in, mirroring the employee convention, gated on the write role because .compliance/ropa.yaml listed no_full_value_read_endpoint as a safeguard for this column; that entry is rewritten rather than left stale, and reveals log actor and customer id but never the value. Also: arcim-migration wrote the identity number as plaintext, which aborts any import containing a Privatperson with 23514 since the constraint flip; and the customer embeds on /api/invoices shipped ciphertext to the browser on every invoice read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: enhance ruta 05 handling for dynamic revenue accounts - Introduced `fetchDynamicRuta05Accounts` to fetch company-specific revenue accounts marked with a VAT rate, addressing issue #1261. - Updated VAT declaration logic to include these dynamic accounts in ruta 05 calculations, ensuring accurate reporting for user-added accounts. - Modified `ACCOUNT_RUTA` to include account 3000 for completeness in ruta 05. - Enhanced tests to validate the inclusion of user-added revenue accounts in ruta 05 and ensure correct VAT calculations. - Seeded default VAT rates for BAS revenue accounts to ensure proper classification in the VAT declaration. * fix: enhance data handling and masking in customer and invoice APIs * fix(vat): resolve the 3000 gruppkonto's rate for the ruta 05 base split 3000 "Forsaljning inom Sverige" is mapped to ruta05 by ACCOUNT_RUTA, so a balance on it is filed in the right box already. What was missing is the rate split: unlike 3001/3002/3003 the account number carries no sats, and fetchDynamicRuta05Accounts skipped it because it is in ACCOUNT_TO_BOX. A company posting to the gruppkonto therefore got a ruta 05 total that breakdown.invoices.base25/12/6 did not add up to. Surface those rates separately as staticRateByAccount: rate-only on purpose, because the static map already sums the account and adding it to the dynamic account list would double the filed figure. A test pins that single-count property. Also add 3000 to the MCP server's RUTA_05_ACCOUNTS, which is the display list behind report.rutor.ruta05: without it a 3000 balance appeared in the filed projection but not in the report the agent reads back. The comment claiming SALES_OUTPUT_VAT_SHORTFALL reads base25/12/6 was wrong and is corrected. That check derives its expected base from the output-VAT rutor (ruta10/0.25 + ruta11/0.12 + ruta12/0.06); nothing reads the per-rate bases, which are reporting metadata. So the incomplete split never affected a filed return or a warning, only the breakdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com>
885 lines
36 KiB
TypeScript
885 lines
36 KiB
TypeScript
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
|
||
import { resolveBookingAccount } from './accruals/account-suggestions'
|
||
import { buildSupplierDescription } from './supplier-invoice-description'
|
||
import {
|
||
generateReverseChargeLines,
|
||
generateReverseChargeBasisLines,
|
||
isReverseChargeBasisAccount,
|
||
resolveReverseChargeRate,
|
||
} from './vat-entries'
|
||
import {
|
||
coerceDimensionsBag,
|
||
dimensionsBagKey,
|
||
mergeDimensionBags,
|
||
type LineDimensions,
|
||
} from './dimension-resolver'
|
||
import { createLogger } from '@/lib/logger'
|
||
import { roundOre } from '@/lib/money'
|
||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import type {
|
||
CreateJournalEntryInput,
|
||
CreateJournalEntryLineInput,
|
||
JournalEntry,
|
||
SupplierInvoice,
|
||
SupplierInvoiceItem,
|
||
} from '@/types'
|
||
|
||
const log = createLogger('supplier-invoice-entries')
|
||
|
||
/**
|
||
* Stable code for the "foreign-currency supplier invoice without a rate"
|
||
* refusal. Registered in lib/errors/structured-errors.ts so REST routes, the
|
||
* MCP server and getErrorMessage() all translate it the same way.
|
||
*/
|
||
export const SI_FX_RATE_MISSING = 'SI_FX_RATE_MISSING' as const
|
||
|
||
/**
|
||
* Raised when a booking path is asked to translate a foreign-currency supplier
|
||
* invoice that has no usable exchange rate.
|
||
*
|
||
* resolveSekAmount() answers that case by returning the RAW foreign amount (a
|
||
* legacy "assume SEK" fallback that is tolerable in read-only code but not
|
||
* when posting a verifikat): a 1 000 EUR invoice would post as 1 000 SEK, and
|
||
* because every leg is scaled by the same wrong factor the entry still
|
||
* balances, so no DB trigger fires and nothing errors. Under omvänd
|
||
* skattskyldighet that silently books 250,00 kr of fiktiv moms on 2614/2645
|
||
* instead of 2 875,00 kr at 11,50 SEK/EUR, understating both ruta 20 (or 21)
|
||
* and ruta 30 of the momsdeklaration by the same amount: an oriktig uppgift
|
||
* exposed to skattetillägg under SFL 49 kap 4 §.
|
||
*
|
||
* The in-house precedent for failing loudly instead is the
|
||
* `match_batch_allocate` RPC, which hard-fails with BATCH_FX_RATE_MISSING, and
|
||
* lib/reports/supplier-ledger.ts, which skips any FX invoice lacking a rate
|
||
* rather than faking a 1:1 conversion.
|
||
*/
|
||
export class SupplierInvoiceFxRateMissingError extends Error {
|
||
readonly code = SI_FX_RATE_MISSING
|
||
constructor(public readonly currency: string) {
|
||
super(
|
||
`Supplier invoice is in ${currency} but has no exchange rate on file; refusing to post it as if 1 ${currency} = 1 SEK.`
|
||
)
|
||
this.name = 'SupplierInvoiceFxRateMissingError'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convert an invoice-currency amount to SEK for a journal entry line.
|
||
*
|
||
* SEK invoices short-circuit exactly as before, and so does any invoice with a
|
||
* legitimately supplied positive rate: the only new behaviour is the refusal
|
||
* above when a foreign invoice reaches a booking path with no rate at all.
|
||
* Every conversion in this file goes through here so no leg (expense, moms,
|
||
* fiktiv moms, basbelopp, 2440) can be posted at a fabricated 1:1 rate.
|
||
*/
|
||
function toSekOrThrow(
|
||
amount: number,
|
||
currency: string,
|
||
exchangeRate: number | null | undefined
|
||
): number {
|
||
if (currency && currency !== 'SEK' && !(exchangeRate != null && exchangeRate > 0)) {
|
||
throw new SupplierInvoiceFxRateMissingError(currency)
|
||
}
|
||
return resolveSekAmount(amount, null, currency, exchangeRate)
|
||
}
|
||
|
||
/**
|
||
* Aggregate item amounts per (booking account, merged dimensions bag):
|
||
* dimensions PR7. The merged bag (item.dimensions over the invoice's
|
||
* default_dimensions) is part of the aggregation identity so two items on the
|
||
* same account but different tags stay on separate journal lines instead of
|
||
* collapsing. Insertion order is first-seen, matching the old per-account map.
|
||
*/
|
||
interface ExpenseBucket {
|
||
account: string
|
||
dimensions?: LineDimensions
|
||
amount: number
|
||
}
|
||
|
||
function groupExpenseBuckets(
|
||
items: SupplierInvoiceItem[],
|
||
resolveAccount: (item: SupplierInvoiceItem) => string,
|
||
toSek: (item: SupplierInvoiceItem) => number,
|
||
defaultDimensions?: LineDimensions
|
||
): ExpenseBucket[] {
|
||
const buckets = new Map<string, ExpenseBucket>()
|
||
for (const item of items) {
|
||
const account = resolveAccount(item)
|
||
const dimensions = mergeDimensionBags(defaultDimensions, item.dimensions)
|
||
const key = `${account}\u0000${dimensionsBagKey(dimensions)}`
|
||
const bucket = buckets.get(key) ?? { account, dimensions, amount: 0 }
|
||
bucket.amount += toSek(item)
|
||
buckets.set(key, bucket)
|
||
}
|
||
return [...buckets.values()]
|
||
}
|
||
|
||
/**
|
||
* Create journal entry when a supplier invoice is registered (accrual method)
|
||
*
|
||
* Swedish domestic (25% VAT):
|
||
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
|
||
* Debit 2641 Ingående moms (per rate) [VAT per rate group]
|
||
* Credit 2440 Leverantörsskulder [total incl VAT]
|
||
*
|
||
* EU/non-EU reverse charge (services):
|
||
* Debit 5xxx/6xxx (per item) [total]
|
||
* Debit 2645 Beräknad ingående moms (per rate) [fiktiv VAT per rate]
|
||
* Credit 26x4 Utgående moms omvänd (per rate) [fiktiv VAT per rate]
|
||
* Credit 2440 Leverantörsskulder [total]
|
||
*
|
||
* Note: Goods imports via Tullverket (customs) use a different accounting path
|
||
* (2615/2645) and are not handled here: only services use reverse charge.
|
||
*/
|
||
export async function createSupplierInvoiceRegistrationEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
invoice: SupplierInvoice,
|
||
items: SupplierInvoiceItem[],
|
||
supplierType: string,
|
||
supplierName?: string
|
||
): Promise<JournalEntry | null> {
|
||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
|
||
if (!fiscalPeriodId) {
|
||
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
|
||
return null
|
||
}
|
||
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||
const isForeign = invoice.currency !== 'SEK'
|
||
// Dimensions PR7: expense lines carry item bags merged over the invoice
|
||
// default; every other line (VAT, RC/basis, 2440) carries the default only.
|
||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||
|
||
// Aggregate expense amounts by (account, dimensions) and convert to SEK.
|
||
// Periodiserade lines book their net to the 17xx interim account instead
|
||
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched:
|
||
// moms is never deferred (redovisas på fakturadatum).
|
||
const expenseBuckets = groupExpenseBuckets(
|
||
items,
|
||
(item) => resolveBookingAccount('expense', item, item.account_number),
|
||
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
|
||
defaultDimensions
|
||
)
|
||
|
||
// Debit: Expense accounts (in SEK)
|
||
const debitLines: CreateJournalEntryLineInput[] = []
|
||
for (const bucket of expenseBuckets) {
|
||
debitLines.push({
|
||
account_number: bucket.account,
|
||
debit_amount: Math.round(bucket.amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
dimensions: bucket.dimensions,
|
||
})
|
||
}
|
||
lines.push(...debitLines)
|
||
|
||
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
|
||
const isDomesticRC = supplierType === 'swedish_business' && invoice.reverse_charge
|
||
|
||
if (isReverseCharge) {
|
||
// Reverse charge: fiktiv moms entries per rate group
|
||
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
|
||
//
|
||
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
|
||
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
|
||
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
|
||
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
|
||
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
|
||
//
|
||
// The basis-account check is done per (rate, account) bucket: if the user
|
||
// booked an item directly to a 44xx/45xx basis account at a given rate,
|
||
// that item's belopp already populates ruta 20-24 via the expense line:
|
||
// we only emit basbeloppsrader for the portion of that rate's base that
|
||
// went to NON-basis accounts. Mixed invoices (4535 + 6540 at 25%) used to
|
||
// skip basis lines entirely under a per-invoice flag, leaving ruta 30
|
||
// larger than ruta 21 by the 6540 portion, the exact FK004 pattern.
|
||
//
|
||
// Drive iteration off the basis (line_total per rate), not stored
|
||
// vat_amount: fiktiv moms is always statutory base × rate. This keeps
|
||
// RC immune to per-line manual VAT overrides (which only make sense for
|
||
// domestic deductible-VAT adjustments).
|
||
const baseByRate = groupBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, invoice.exchange_rate)
|
||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||
for (const [rate, baseAmount] of baseByRate) {
|
||
if (rate > 0 && baseAmount > 0) {
|
||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||
if (nonBasisBase > 0) {
|
||
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
|
||
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||
}
|
||
}
|
||
}
|
||
} else if (itemsHaveVat(items)) {
|
||
// Domestic standard: Debit ingående moms per rate group
|
||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||
for (const [rate, amount] of vatByRate) {
|
||
if (amount > 0) {
|
||
lines.push({
|
||
account_number: '2641',
|
||
debit_amount: Math.round(amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Credit: Leverantörsskulder, balance guarantee: ensures sum(debits) === sum(credits)
|
||
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
|
||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||
lines.push({
|
||
account_number: '2440',
|
||
debit_amount: 0,
|
||
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
|
||
line_description: desc,
|
||
dimensions: defaultDimensions,
|
||
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
|
||
})
|
||
|
||
const input: CreateJournalEntryInput = {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: invoice.invoice_date,
|
||
description: desc,
|
||
source_type: 'supplier_invoice_registered',
|
||
source_id: invoice.id,
|
||
lines,
|
||
}
|
||
|
||
return createJournalEntry(supabase, companyId, userId, input)
|
||
}
|
||
|
||
/**
|
||
* Create journal entry when a supplier invoice is paid (accrual method)
|
||
*
|
||
* Debit 2440 Leverantörsskulder [payment amount]
|
||
* Credit 1930 Företagskonto [payment amount]
|
||
*
|
||
* With exchange rate difference:
|
||
* Debit 2440 Leverantörsskulder [original SEK amount]
|
||
* Credit 1930 Företagskonto [actual SEK paid]
|
||
* Credit/Debit 3960/7960 [difference]
|
||
*/
|
||
export async function createSupplierInvoicePaymentEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
invoice: SupplierInvoice,
|
||
paymentAmount: number,
|
||
paymentDate: string,
|
||
exchangeRateDifference?: number,
|
||
supplierName?: string,
|
||
paymentAccount?: string
|
||
): Promise<JournalEntry | null> {
|
||
const creditAccount = paymentAccount || '1930'
|
||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||
if (!fiscalPeriodId) {
|
||
log.warn('No open fiscal period found for payment date:', paymentDate)
|
||
return null
|
||
}
|
||
|
||
const desc = buildSupplierDescription('Utbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
// Dimensions PR7: the payment voucher re-propagates the linked invoice's
|
||
// default bag onto every leg (incl. FX result lines), see the stamp below.
|
||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||
|
||
if (exchangeRateDifference && exchangeRateDifference !== 0) {
|
||
// Foreign currency with exchange rate difference
|
||
const originalSekAmount = paymentAmount
|
||
const actualSekPaid = paymentAmount - exchangeRateDifference
|
||
|
||
// Debit: Clear leverantörsskulder at original booked SEK amount
|
||
lines.push({
|
||
account_number: '2440',
|
||
debit_amount: Math.round(originalSekAmount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
})
|
||
|
||
// Credit: Bank at actual SEK paid
|
||
lines.push({
|
||
account_number: creditAccount,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(actualSekPaid * 100) / 100,
|
||
line_description: desc,
|
||
})
|
||
|
||
// Exchange rate difference
|
||
if (exchangeRateDifference > 0) {
|
||
// Gain: Credit 3960
|
||
lines.push({
|
||
account_number: '3960',
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||
line_description: 'Valutakursvinst',
|
||
})
|
||
} else {
|
||
// Loss: Debit 7960
|
||
lines.push({
|
||
account_number: '7960',
|
||
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: 'Valutakursförlust',
|
||
})
|
||
}
|
||
} else {
|
||
// Standard SEK payment
|
||
lines.push({
|
||
account_number: '2440',
|
||
debit_amount: Math.round(paymentAmount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
})
|
||
|
||
lines.push({
|
||
account_number: creditAccount,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(paymentAmount * 100) / 100,
|
||
line_description: desc,
|
||
})
|
||
}
|
||
|
||
if (defaultDimensions) {
|
||
// Copy per line: a shared bag object would let one line's mutation
|
||
// leak into every other line (same contract as proposal stamping).
|
||
for (const line of lines) line.dimensions = { ...defaultDimensions }
|
||
}
|
||
|
||
const input: CreateJournalEntryInput = {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: paymentDate,
|
||
description: desc,
|
||
source_type: 'supplier_invoice_paid',
|
||
source_id: invoice.id,
|
||
lines,
|
||
}
|
||
|
||
return createJournalEntry(supabase, companyId, userId, input)
|
||
}
|
||
|
||
/**
|
||
* Create journal entry for cash method (kontantmetoden)
|
||
* Combined entry at payment time:
|
||
*
|
||
* Debit 5xxx/6xxx (per item) [line_total]
|
||
* Debit 2641 Ingående moms [total VAT]
|
||
* Credit 1930 Företagskonto [total incl VAT]
|
||
*/
|
||
export async function createSupplierInvoiceCashEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
invoice: SupplierInvoice,
|
||
items: SupplierInvoiceItem[],
|
||
paymentDate: string,
|
||
supplierType: string,
|
||
supplierName?: string,
|
||
paymentAccount?: string,
|
||
// SEK that actually settled the invoice (the amount that left the bank). For
|
||
// a foreign-currency invoice this pins the whole entry to the PAYMENT-date
|
||
// rate, see the kontantmetoden note below. Omit for SEK invoices and the
|
||
// behaviour is byte-identical to before.
|
||
settledBankSek?: number
|
||
): Promise<JournalEntry | null> {
|
||
const creditAccount = paymentAccount || '1930'
|
||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, paymentDate)
|
||
if (!fiscalPeriodId) {
|
||
log.warn('No open fiscal period found for payment date:', paymentDate)
|
||
return null
|
||
}
|
||
|
||
// Under kontantmetoden the booked affärshändelse IS the payment (BFL 5 kap:
|
||
// "bokföring vid betalningstillfället"), so the entire verifikat is translated
|
||
// at the PAYMENT-date rate (ÅRL 4 kap 6 §). There is no kursvinst/kursförlust
|
||
// because no leverantörsskuld was ever carried at a historical rate: that
|
||
// only happens under faktureringsmetoden (handled by the 2440-clearing path
|
||
// with 7960/3960). When the caller passes the SEK that actually settled the
|
||
// invoice, we derive the implied payment-date rate from it so the payment-
|
||
// account credit equals the bank movement to the öre. For SEK invoices, or
|
||
// when no settlement SEK is supplied, we keep the invoice's stored rate.
|
||
const isForeign = invoice.currency !== 'SEK'
|
||
const useSettlementRate =
|
||
settledBankSek != null && settledBankSek > 0 && isForeign && invoice.total > 0
|
||
const effectiveRate = useSettlementRate
|
||
? settledBankSek / invoice.total
|
||
: invoice.exchange_rate
|
||
|
||
const desc = buildSupplierDescription('Kontantbetalning leverantörsfaktura', invoice.supplier_invoice_number, supplierName)
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
// Dimensions PR7: kontantmetoden books the expense at payment, same merge
|
||
// rules as the registration entry.
|
||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||
// Expense debit lines tracked separately so a sub-öre translation residual
|
||
// can be folded into the largest one (öresavrundning step below).
|
||
const expenseLines: CreateJournalEntryLineInput[] = []
|
||
|
||
// Aggregate expense amounts by (account, dimensions) and convert to SEK
|
||
const expenseBuckets = groupExpenseBuckets(
|
||
items,
|
||
(item) => item.account_number,
|
||
(item) => toSekOrThrow(item.line_total, invoice.currency, effectiveRate),
|
||
defaultDimensions
|
||
)
|
||
|
||
// Debit: Expense accounts (in SEK)
|
||
for (const bucket of expenseBuckets) {
|
||
const line: CreateJournalEntryLineInput = {
|
||
account_number: bucket.account,
|
||
debit_amount: Math.round(bucket.amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
dimensions: bucket.dimensions,
|
||
}
|
||
lines.push(line)
|
||
expenseLines.push(line)
|
||
}
|
||
|
||
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && invoice.reverse_charge
|
||
const isDomesticRC = supplierType === 'swedish_business' && invoice.reverse_charge
|
||
|
||
if (isReverseCharge) {
|
||
// Reverse charge: fiktiv moms entries per rate group
|
||
// Domestic (byggtjänster etc.): 2647/26x4, EU/non-EU: 2645/26x4
|
||
//
|
||
// Also generate basbeloppsrader on 44xx/45xx + motkonto 4598 so SKV's
|
||
// momsdeklaration ruta 20-24 reflects the underlying purchase amount.
|
||
// Without these the fiktiv moms (2614/2624/2634) populates ruta 30-32
|
||
// but ruta 20-24 stay at 0, which Skatteverket rejects with felkod
|
||
// FK004 ("silent netting prohibited"; ML 13 kap kräver båda sidor).
|
||
// Per-rate bucketing: see registration entry above for the FK004 rationale.
|
||
// Drive iteration off the basis (line_total per rate): fiktiv moms is
|
||
// always statutory base × rate; manual vat_amount overrides don't apply.
|
||
// effectiveRate (payment-date rate under kontantmetoden) keeps the fiktiv
|
||
// moms base consistent with the expense lines above.
|
||
const baseByRate = groupBaseByRate(items, invoice.currency, effectiveRate)
|
||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, invoice.currency, effectiveRate)
|
||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||
for (const [rate, baseAmount] of baseByRate) {
|
||
if (rate > 0 && baseAmount > 0) {
|
||
const rcLines = generateReverseChargeLines(baseAmount, rate, isDomesticRC)
|
||
lines.push(...rcLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||
if (nonBasisBase > 0) {
|
||
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
|
||
lines.push(...basisLines.map((l) => ({ ...l, dimensions: defaultDimensions })))
|
||
}
|
||
}
|
||
}
|
||
} else if (itemsHaveVat(items)) {
|
||
// Domestic standard: Debit ingående moms per rate group (at the payment-
|
||
// date rate when settling a foreign invoice, see effectiveRate above).
|
||
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
|
||
for (const [rate, amount] of vatByRate) {
|
||
if (amount > 0) {
|
||
lines.push({
|
||
account_number: '2641',
|
||
debit_amount: Math.round(amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Öresavrundning: when translating a foreign invoice at the payment-date
|
||
// rate, per-line rounding can drift the implied bank total by an öre or two.
|
||
// Fold that residual into the largest expense line so the payment-account
|
||
// credit lands exactly on the SEK that left the bank (1930 reconciles to the
|
||
// bank transaction). Immaterial to the momsdeklaration: rutor are whole
|
||
// kronor. The |residual| ≤ 1 guard ensures we only absorb rounding noise,
|
||
// never a real shortfall (a partial settlement is blocked upstream).
|
||
if (useSettlementRate && expenseLines.length > 0) {
|
||
const debitSum = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||
const creditSum = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||
const provisionalCredit = roundOre(debitSum - creditSum)
|
||
const residual = roundOre(settledBankSek! - provisionalCredit)
|
||
if (residual !== 0 && Math.abs(residual) <= 1) {
|
||
const target = expenseLines.reduce((a, b) => (b.debit_amount >= a.debit_amount ? b : a))
|
||
target.debit_amount = roundOre(target.debit_amount + residual)
|
||
}
|
||
}
|
||
|
||
// Credit: payment account, balance guarantee: ensures sum(debits) === sum(credits)
|
||
// For reverse charge, intermediate credits (2614/2624/2634) already exist, so we subtract them
|
||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||
lines.push({
|
||
account_number: creditAccount,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round((totalDebits - totalCredits) * 100) / 100,
|
||
line_description: desc,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
|
||
const input: CreateJournalEntryInput = {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: paymentDate,
|
||
description: desc,
|
||
source_type: 'supplier_invoice_cash_payment',
|
||
source_id: invoice.id,
|
||
lines,
|
||
}
|
||
|
||
return createJournalEntry(supabase, companyId, userId, input)
|
||
}
|
||
|
||
/**
|
||
* Create journal entry for an invoice paid with the owner's private funds
|
||
* (eget utlägg). The AP leg is bypassed entirely: instead of crediting 2440
|
||
* and later debiting it on mark-paid, the expense lines book straight against
|
||
* the owner's payable/equity account:
|
||
*
|
||
* Debit 5xxx/6xxx (per item) [line_total in SEK]
|
||
* Debit 2641 Ingående moms [VAT per rate]
|
||
* Credit 2893 / 2018 [total incl VAT]
|
||
*
|
||
* Reverse charge is intentionally not supported here. RC invoices are
|
||
* never "I paid this cash at a kiosk" cases: they're EU/byggtjänster from
|
||
* registered businesses with formal invoices, which always go through AP.
|
||
* The API route guards against this combo before calling us.
|
||
*/
|
||
export async function createSupplierInvoicePrivatelyPaidEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
invoice: SupplierInvoice,
|
||
items: SupplierInvoiceItem[],
|
||
entityType: 'aktiebolag' | 'enskild_firma',
|
||
supplierName?: string
|
||
): Promise<JournalEntry | null> {
|
||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, invoice.invoice_date)
|
||
if (!fiscalPeriodId) {
|
||
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
|
||
return null
|
||
}
|
||
|
||
const ownerAccount = entityType === 'aktiebolag' ? '2893' : '2018'
|
||
const desc = buildSupplierDescription('Eget utlägg', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
// Dimensions PR7: this IS the utlägg path, billable-expense-to-project
|
||
// tagging rides the same merge rules as the registration entry.
|
||
const defaultDimensions = coerceDimensionsBag(invoice.default_dimensions)
|
||
|
||
// Debit: Expense accounts (in SEK), aggregated per (account, dimensions)
|
||
const expenseBuckets = groupExpenseBuckets(
|
||
items,
|
||
(item) => item.account_number,
|
||
(item) => toSekOrThrow(item.line_total, invoice.currency, invoice.exchange_rate),
|
||
defaultDimensions
|
||
)
|
||
for (const bucket of expenseBuckets) {
|
||
lines.push({
|
||
account_number: bucket.account,
|
||
debit_amount: Math.round(bucket.amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
dimensions: bucket.dimensions,
|
||
})
|
||
}
|
||
|
||
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
|
||
if (itemsHaveVat(items)) {
|
||
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
|
||
for (const [rate, amount] of vatByRate) {
|
||
if (amount > 0) {
|
||
lines.push({
|
||
account_number: '2641',
|
||
debit_amount: Math.round(amount * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Credit: Owner payable/equity, balance guarantee
|
||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||
lines.push({
|
||
account_number: ownerAccount,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(totalDebits * 100) / 100,
|
||
line_description: desc,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
|
||
const input: CreateJournalEntryInput = {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: invoice.invoice_date,
|
||
description: desc,
|
||
source_type: 'supplier_invoice_privately_paid',
|
||
source_id: invoice.id,
|
||
lines,
|
||
}
|
||
|
||
return createJournalEntry(supabase, companyId, userId, input)
|
||
}
|
||
|
||
/**
|
||
* Create journal entry for a supplier credit note (reversal of registration)
|
||
*
|
||
* Debit 2440 Leverantörsskulder [total]
|
||
* Credit 5xxx/6xxx (per item) [line_total]
|
||
* Credit 2641 Ingående moms [total VAT]
|
||
*/
|
||
export async function createSupplierCreditNoteEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
creditNote: SupplierInvoice,
|
||
items: SupplierInvoiceItem[],
|
||
supplierType: string,
|
||
supplierName?: string
|
||
): Promise<JournalEntry | null> {
|
||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, creditNote.invoice_date)
|
||
if (!fiscalPeriodId) {
|
||
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
|
||
return null
|
||
}
|
||
|
||
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
|
||
const lines: CreateJournalEntryLineInput[] = []
|
||
// Dimensions PR7: items are the ORIGINAL invoice's (see below), so their
|
||
// bags reverse against the same dimension cells; the credit note's own
|
||
// default (copied from the original at credit time) rides the other legs.
|
||
const defaultDimensions = coerceDimensionsBag(creditNote.default_dimensions)
|
||
|
||
// Credit: Expense accounts (reverse, in SEK). The caller passes the
|
||
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
|
||
// interim account they were registered on (the schedule's posted
|
||
// dissolutions are stornoed separately by cancelSchedulesForSource).
|
||
const creditLines: CreateJournalEntryLineInput[] = []
|
||
const expenseBuckets = groupExpenseBuckets(
|
||
items,
|
||
(item) => resolveBookingAccount('expense', item, item.account_number),
|
||
(item) => Math.abs(toSekOrThrow(item.line_total, creditNote.currency, creditNote.exchange_rate)),
|
||
defaultDimensions
|
||
)
|
||
|
||
for (const bucket of expenseBuckets) {
|
||
creditLines.push({
|
||
account_number: bucket.account,
|
||
debit_amount: 0,
|
||
credit_amount: Math.round(bucket.amount * 100) / 100,
|
||
line_description: desc,
|
||
dimensions: bucket.dimensions,
|
||
})
|
||
}
|
||
|
||
const isReverseCharge = (supplierType === 'eu_business' || supplierType === 'non_eu_business' || supplierType === 'swedish_business') && creditNote.reverse_charge
|
||
const isDomesticRC = supplierType === 'swedish_business' && creditNote.reverse_charge
|
||
|
||
if (isReverseCharge) {
|
||
// Reverse the fiktiv moms per rate group (swap debit/credit from registration)
|
||
// Input VAT account: 2647 for domestic RC, 2645 for EU/non-EU
|
||
// Drive iteration off the basis: fiktiv moms is always statutory base × rate.
|
||
const inputAccount = isDomesticRC ? '2647' : '2645'
|
||
const baseByRate = groupBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||
const nonBasisBaseByRate = groupNonBasisBaseByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||
const rcSupplierType = supplierType as 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||
// Only reverse basbeloppsraderna for the portion the registration would
|
||
// have emitted them, namely the non-basis-account base per rate. Items
|
||
// booked directly to 44xx/45xx had no parallel basis lines in registration
|
||
// and so are reversed only via the expense credit line above.
|
||
for (const [rate, baseAmount] of baseByRate) {
|
||
if (rate > 0 && baseAmount > 0) {
|
||
const fiktivVat = Math.round(baseAmount * rate * 100) / 100
|
||
// Determine the output account for this rate
|
||
let outputAccount: string
|
||
switch (rate) {
|
||
case 0.12: outputAccount = '2624'; break
|
||
case 0.06: outputAccount = '2634'; break
|
||
default: outputAccount = '2614'; break
|
||
}
|
||
creditLines.push({
|
||
account_number: inputAccount,
|
||
debit_amount: 0,
|
||
credit_amount: fiktivVat,
|
||
line_description: `Omvänd fiktiv ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
lines.push({
|
||
account_number: outputAccount,
|
||
debit_amount: fiktivVat,
|
||
credit_amount: 0,
|
||
line_description: `Omvänd fiktiv utgående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
const nonBasisBase = nonBasisBaseByRate.get(rate) || 0
|
||
if (nonBasisBase > 0) {
|
||
// Reverse the basbeloppsrader (44xx/45xx debit & 4598 credit on the
|
||
// registration entry become credits & debits here). Without this the
|
||
// credit note would only undo the VAT amounts (ruta 30-32 + 48) but
|
||
// leave ruta 20-24 still showing the original basbelopp, exactly
|
||
// the same FK004-style mismatch the registration fix prevents.
|
||
const basisLines = generateReverseChargeBasisLines(nonBasisBase, rate, rcSupplierType)
|
||
// Swap debit/credit on every basis line so the credit note nets
|
||
// against the original registration verifikat.
|
||
for (const line of basisLines) {
|
||
lines.push({
|
||
account_number: line.account_number,
|
||
debit_amount: line.credit_amount,
|
||
credit_amount: line.debit_amount,
|
||
line_description: line.line_description,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Domestic: Credit ingående moms per rate group (reverse)
|
||
const vatByRate = groupVatByRate(items, creditNote.currency, creditNote.exchange_rate, true)
|
||
for (const [rate, amount] of vatByRate) {
|
||
if (amount > 0) {
|
||
creditLines.push({
|
||
account_number: '2641',
|
||
debit_amount: 0,
|
||
credit_amount: amount,
|
||
line_description: `Ingående moms ${Math.round(rate * 100)}% ${desc}`,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
lines.push(...creditLines)
|
||
|
||
// Debit: Leverantörsskulder, balance guarantee: debit = sum of credits minus other debits
|
||
const totalCredits = lines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||
const totalDebits = lines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||
lines.unshift({
|
||
account_number: '2440',
|
||
debit_amount: Math.round((totalCredits - totalDebits) * 100) / 100,
|
||
credit_amount: 0,
|
||
line_description: desc,
|
||
dimensions: defaultDimensions,
|
||
})
|
||
|
||
const input: CreateJournalEntryInput = {
|
||
fiscal_period_id: fiscalPeriodId,
|
||
entry_date: creditNote.invoice_date,
|
||
description: desc,
|
||
source_type: 'supplier_credit_note',
|
||
source_id: creditNote.id,
|
||
lines,
|
||
}
|
||
|
||
return createJournalEntry(supabase, companyId, userId, input)
|
||
}
|
||
|
||
/**
|
||
* Group items by VAT rate and sum the stored VAT amount per rate.
|
||
* Returns a Map<rate, totalVatAmount> in SEK for per-rate 2641 journal lines.
|
||
*
|
||
* Reads `item.vat_amount` directly: set by the API from the line's manual
|
||
* override when present, else computed line_total × rate. This is the path
|
||
* for partial-deductible cases (bilförmån 50%, representation 300 kr-tak),
|
||
* foreign-currency rounding, and supplier POS rounding.
|
||
*
|
||
* Fallback to line_total × rate when vat_amount is null/0 but rate > 0:
|
||
* legacy import paths (SIE, CSV, demo seed) sometimes leave vat_amount at
|
||
* the column DEFAULT of 0. Silently dropping input VAT to 2641 would
|
||
* understate ruta 48 in the momsdeklaration.
|
||
*
|
||
* Reverse-charge fiktiv moms doesn't use this: see groupBaseByRate, which
|
||
* derives the basis directly so fiktiv VAT is always base × statutory rate.
|
||
*/
|
||
/**
|
||
* True if any item would produce a non-zero ingående moms line: mirrors the
|
||
* same stored-vs-computed fallback groupVatByRate uses (stored vat_amount,
|
||
* else line_total × rate). Callers must gate the VAT branch on this instead
|
||
* of invoice.vat_amount: that header field can come from a source (e.g. the
|
||
* MCP inbox-conversion tool's OCR-extracted totals) that is never
|
||
* reconciled against the items, so a stale or zero header would otherwise
|
||
* silently suppress a correct per-line VAT posting.
|
||
*/
|
||
function itemsHaveVat(items: SupplierInvoiceItem[]): boolean {
|
||
return items.some((item) => {
|
||
if ((item.vat_amount ?? 0) > 0) return true
|
||
const rate = item.vat_rate ?? 0.25
|
||
return rate > 0 && (item.line_total ?? 0) > 0
|
||
})
|
||
}
|
||
|
||
function groupVatByRate(
|
||
items: SupplierInvoiceItem[],
|
||
currency: string,
|
||
exchangeRate: number | null,
|
||
useAbsoluteValues = false
|
||
): Map<number, number> {
|
||
const vatByRate = new Map<number, number>()
|
||
for (const item of items) {
|
||
const rate = item.vat_rate ?? 0.25
|
||
const storedVat = item.vat_amount ?? 0
|
||
const computedVat = rate > 0
|
||
? Math.round((item.line_total ?? 0) * rate * 100) / 100
|
||
: 0
|
||
const sourceVat = storedVat > 0 ? storedVat : computedVat
|
||
let vatSek = toSekOrThrow(sourceVat, currency, exchangeRate)
|
||
if (useAbsoluteValues) vatSek = Math.abs(vatSek)
|
||
vatByRate.set(rate, (vatByRate.get(rate) || 0) + vatSek)
|
||
}
|
||
return vatByRate
|
||
}
|
||
|
||
/**
|
||
* Group items by their self-assessed reverse-charge rate and sum the base
|
||
* (line_total) per rate. Used by reverse-charge paths to compute fiktiv moms
|
||
* from the basis, decoupled from any manual VAT override on the items.
|
||
*
|
||
* The grouping key is the *self-assessed* rate (resolveReverseChargeRate), not
|
||
* the line's vat_rate: under omvänd skattskyldighet the supplier charges 0%, so
|
||
* the line vat_rate is 0, but the buyer self-assesses at 25% (huvudregeln) or
|
||
* the explicit per-item reverse_charge_rate. Without this a 0%-rate RC line
|
||
* would key on rate 0 and the `rate > 0` guard below would skip its VAT lines.
|
||
*/
|
||
function groupBaseByRate(
|
||
items: SupplierInvoiceItem[],
|
||
currency: string,
|
||
exchangeRate: number | null,
|
||
useAbsoluteValues = false
|
||
): Map<number, number> {
|
||
const baseByRate = new Map<number, number>()
|
||
for (const item of items) {
|
||
const rate = resolveReverseChargeRate(item)
|
||
let baseSek = toSekOrThrow(item.line_total, currency, exchangeRate)
|
||
if (useAbsoluteValues) baseSek = Math.abs(baseSek)
|
||
baseByRate.set(rate, (baseByRate.get(rate) || 0) + baseSek)
|
||
}
|
||
return baseByRate
|
||
}
|
||
|
||
/**
|
||
* Sum, per VAT rate, the base (line_total in SEK) of items booked to
|
||
* non-basis expense accounts. Items already booked to a 44xx/45xx basis
|
||
* account populate ruta 20-24 directly via the expense line, so they must be
|
||
* excluded here to avoid double-counting in basbeloppsraderna.
|
||
*/
|
||
function groupNonBasisBaseByRate(
|
||
items: SupplierInvoiceItem[],
|
||
currency: string,
|
||
exchangeRate: number | null,
|
||
useAbsoluteValues = false
|
||
): Map<number, number> {
|
||
const baseByRate = new Map<number, number>()
|
||
for (const item of items) {
|
||
if (isReverseChargeBasisAccount(item.account_number)) continue
|
||
const rate = resolveReverseChargeRate(item)
|
||
let itemSek = toSekOrThrow(item.line_total, currency, exchangeRate)
|
||
if (useAbsoluteValues) itemSek = Math.abs(itemSek)
|
||
baseByRate.set(rate, (baseByRate.get(rate) || 0) + itemSek)
|
||
}
|
||
return baseByRate
|
||
}
|