Files
accounted/components/reports/views/index.tsx
T
65c6d4c178 Fix/07 27 (#1271)
* 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>
2026-07-28 19:50:16 +02:00

3561 lines
137 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
// Report view components, extracted verbatim from app/(dashboard)/reports/page.tsx.
// Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx).
// The regulated table/figure rendering is unchanged from the original monolith.
import React, { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { cn, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { formatLatestVouchers, LATEST_VOUCHERS_LABEL } from '@/lib/reports/latest-vouchers-format'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AccountNumber } from '@/components/ui/account-number'
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
import { PageHeader } from '@/components/ui/page-header'
import { VatChecksCard } from '@/components/reports/VatChecksCard'
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration'
import {
isFilingBlocked,
withRcBasisGapFindings,
type RcBasisGapScan,
} from '@/lib/reports/vat-filing-gate'
import { Table, TableBody } from '@/components/ui/table'
import { useCompanySettings } from '@/components/settings/useSettings'
import dynamic from 'next/dynamic'
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { VatSettlementProposal } from '@/lib/reports/vat-settlement'
// Recharts is ~180KB: defer the chart components so report tables (the
// regulated content) render without waiting for the charting bundle.
const chartFallback = () => <Skeleton className="h-64 w-full" />
const TrialBalanceChart = dynamic(
() => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart),
{ ssr: false, loading: chartFallback },
)
const IncomeExpenseChart = dynamic(
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
{ ssr: false, loading: chartFallback },
)
// The full journal entry editor is heavy (BAS catalogue, comboboxes, review
// dialogs): defer it until the user opens the momsverifikat dialog.
const JournalEntryForm = dynamic(() => import('@/components/bookkeeping/JournalEntryForm'), {
ssr: false,
loading: () => <Skeleton className="h-64 w-full" />,
})
import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion'
import type {
ReportSourceLine,
ReportSourceFetcher,
} from '@/lib/reports/source-lines'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import type { DateRangeValue } from '@/components/common/ReportDateRange'
import type { DimensionFilterValue } from '@/components/reports/DimensionFilter'
import type {
TrialBalanceRow,
IncomeStatementReport,
BalanceSheetReport,
ResultatrapportReport,
BalansrapportReport,
DimensionPnlReport,
VatDeclaration,
VatPeriodType,
} from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function reportQuery(
periodId: string,
range?: DateRangeValue,
dimensionFilter?: DimensionFilterValue | null,
): string {
const params = new URLSearchParams({ period_id: periodId })
if (range?.fromDate) params.set('from_date', range.fromDate)
if (range?.toDate) params.set('to_date', range.toDate)
if (dimensionFilter) {
params.set('dim_no', dimensionFilter.dimNo)
params.set('dim_code', dimensionFilter.code)
}
return params.toString()
}
export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<{
rows: TrialBalanceRow[]
totalDebit: number
totalCredit: number
isBalanced: boolean
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'simplified' | 'detailed'>('simplified')
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/trial-balance?period_id=${periodId}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
setLoading(false)
})
.catch(() => {
setError('Kunde inte hämta saldobalans')
setLoading(false)
})
}, [periodId])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.rows.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda verifikationer i denna period.
</CardContent>
</Card>
)
}
function getNetBalance(row: TrialBalanceRow, type: 'opening' | 'period' | 'closing'): number {
let debit: number, credit: number
if (type === 'opening') {
debit = row.opening_debit; credit = row.opening_credit
} else if (type === 'period') {
debit = row.period_debit; credit = row.period_credit
} else {
debit = row.closing_debit; credit = row.closing_credit
}
// Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit
// Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit
const creditNormal = row.account_class === 2 || row.account_class === 3
return roundOre(creditNormal ? credit - debit : debit - credit)
}
function formatSigned(amount: number): string {
if (amount === 0) return ''
return amount < 0
? `−${formatAmount(Math.abs(amount))}`
: formatAmount(amount)
}
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/trial-balance/xlsx?period_id=${periodId}` }]} />
<TrialBalanceChart rows={data.rows} />
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Saldobalans</CardTitle>
<div className="flex items-center gap-3">
<div className="flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5">
<button
onClick={() => setViewMode('simplified')}
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
viewMode === 'simplified'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Förenklad
</button>
<button
onClick={() => setViewMode('detailed')}
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
viewMode === 'detailed'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Detaljerad
</button>
</div>
{data.isBalanced ? (
<Badge variant="success">Balanserad</Badge>
) : (
<Badge variant="destructive">Ej balanserad</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-2 px-2">
{viewMode === 'simplified' ? (
<table className="w-full text-sm min-w-[500px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2 w-20">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-32 text-right">Ingående saldo</th>
<th className="py-2 w-32 text-right">Förändring</th>
<th className="py-2 w-32 text-right">Utgående saldo</th>
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<TrialBalanceSimplifiedRow
key={row.account_number}
row={row}
periodId={periodId}
onNavigateToAccount={onNavigateToAccount}
getNetBalance={getNetBalance}
formatSigned={formatSigned}
/>
))}
</tbody>
</table>
) : (
<table className="w-full text-sm min-w-[600px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2 w-20">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-28 text-right">Period debet</th>
<th className="py-2 w-28 text-right">Period kredit</th>
<th className="py-2 w-28 text-right">Saldo debet</th>
<th className="py-2 w-28 text-right">Saldo kredit</th>
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<TrialBalanceDetailedRow
key={row.account_number}
row={row}
periodId={periodId}
onNavigateToAccount={onNavigateToAccount}
/>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td className="py-2"></td>
<td colSpan={2} className="py-2">Summa</td>
<td className="py-2 text-right">
{formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))}
</td>
<td className="py-2 text-right">
{formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))}
</td>
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
{formatAmount(data.totalDebit)}
</td>
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
{formatAmount(data.totalCredit)}
</td>
</tr>
</tfoot>
</table>
)}
</div>
</CardContent>
</Card>
</div>
)
}
// Lazy fetcher for a TB account's source lines. Memoised at the row level so
// repeated toggling never refetches.
function makeTrialBalanceFetcher(accountNumber: string, periodId: string): ReportSourceFetcher {
return async () => {
const res = await fetch(
`/api/reports/trial-balance/account/${encodeURIComponent(accountNumber)}/sources?fiscal_period_id=${encodeURIComponent(periodId)}`
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat')
const lines: ReportSourceLine[] = json.data?.lines || []
return { lines, next_cursor: json.data?.next_cursor ?? null }
}
}
function TrialBalanceSimplifiedRow({
row,
periodId,
onNavigateToAccount,
getNetBalance,
formatSigned,
}: {
row: TrialBalanceRow
periodId: string
onNavigateToAccount: (account: string) => void
getNetBalance: (row: TrialBalanceRow, type: 'opening' | 'period' | 'closing') => number
formatSigned: (amount: number) => string
}) {
const fetcher = React.useMemo(
() => makeTrialBalanceFetcher(row.account_number, periodId),
[row.account_number, periodId]
)
const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-${row.account_number}`)
const ob = getNetBalance(row, 'opening')
const ch = getNetBalance(row, 'period')
const cb = getNetBalance(row, 'closing')
return (
<>
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-2" onClick={(e) => e.stopPropagation()}>
<Toggle />
</td>
<td
className="py-2 cursor-pointer"
onClick={() => onNavigateToAccount(row.account_number)}
>
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td
className="py-2 cursor-pointer"
onClick={() => onNavigateToAccount(row.account_number)}
>
{row.account_name}
</td>
<td className={`py-2 text-right tabular-nums ${ob < 0 ? 'text-destructive' : ''}`}>
{formatSigned(ob)}
</td>
<td className={`py-2 text-right tabular-nums ${ch < 0 ? 'text-destructive' : ''}`}>
{formatSigned(ch)}
</td>
<td className={`py-2 text-right tabular-nums font-medium ${cb < 0 ? 'text-destructive' : ''}`}>
{formatSigned(cb)}
</td>
</tr>
<Panel colSpan={6} />
</>
)
}
function TrialBalanceDetailedRow({
row,
periodId,
onNavigateToAccount,
}: {
row: TrialBalanceRow
periodId: string
onNavigateToAccount: (account: string) => void
}) {
const fetcher = React.useMemo(
() => makeTrialBalanceFetcher(row.account_number, periodId),
[row.account_number, periodId]
)
const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-det-${row.account_number}`)
return (
<>
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
<td className="py-2" onClick={(e) => e.stopPropagation()}>
<Toggle />
</td>
<td
className="py-2 cursor-pointer"
onClick={() => onNavigateToAccount(row.account_number)}
>
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td
className="py-2 cursor-pointer"
onClick={() => onNavigateToAccount(row.account_number)}
>
{row.account_name}
</td>
<td className="py-2 text-right">
{row.period_debit > 0 ? formatAmount(row.period_debit) : ''}
</td>
<td className="py-2 text-right">
{row.period_credit > 0 ? formatAmount(row.period_credit) : ''}
</td>
<td className="py-2 text-right">
{row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''}
</td>
<td className="py-2 text-right">
{row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''}
</td>
</tr>
<Panel colSpan={7} />
</>
)
}
export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<IncomeStatementReport | null>(null)
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
const [monthlyLoading, setMonthlyLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
useEffect(() => {
setLoading(true)
setError(null)
setMonthlyLoading(true)
fetch(`/api/reports/income-statement?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
setLoading(false)
})
.catch(() => {
setError('Kunde inte hämta resultaträkning')
setLoading(false)
})
// Monthly breakdown is full-period by design (it IS the per-month view),
// so the date range only affects the headline numbers above the chart.
// The dimension filter DOES apply: a dimension-scoped view must not
// silently chart company-wide months.
fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`)
.then((res) => res.json())
.then((result) => {
if (result.data?.months) {
setMonthlyData(result.data.months)
}
setMonthlyLoading(false)
})
.catch(() => {
setMonthlyLoading(false)
})
}, [periodId, reportQs])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data för denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<ReportExportMenu
items={[
{ format: 'pdf', href: `/api/reports/income-statement/pdf?${reportQs}` },
{ format: 'xlsx', href: `/api/reports/income-statement/xlsx?${reportQs}` },
]}
/>
{!monthlyLoading && monthlyData.length > 0 && (
<IncomeExpenseChart months={monthlyData} />
)}
{/* Revenue */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Rörelseintäkter</CardTitle>
</CardHeader>
<CardContent>
<ReportSectionTable sections={data.revenue_sections} onNavigateToAccount={onNavigateToAccount} />
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
<span>Summa rörelseintäkter</span>
<span>{formatAmount(data.total_revenue)} kr</span>
</div>
</CardContent>
</Card>
{/* Expenses */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Rörelsekostnader</CardTitle>
</CardHeader>
<CardContent>
<ReportSectionTable sections={data.expense_sections} negate onNavigateToAccount={onNavigateToAccount} />
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
<span>Summa rörelsekostnader</span>
<span>-{formatAmount(data.total_expenses)} kr</span>
</div>
</CardContent>
</Card>
{/* Operating result */}
<Card>
<CardContent className="py-4">
<div className="flex justify-between font-bold text-lg">
<span>Rörelseresultat</span>
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
{formatAmount(data.total_revenue - data.total_expenses)} kr
</span>
</div>
</CardContent>
</Card>
{/* Financial items */}
{data.financial_sections.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Finansiella poster</CardTitle>
</CardHeader>
<CardContent>
<ReportSectionTable sections={data.financial_sections} onNavigateToAccount={onNavigateToAccount} />
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
<span>Summa finansiella poster</span>
<span>{formatAmount(data.total_financial)} kr</span>
</div>
</CardContent>
</Card>
)}
{/* Net result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between font-bold text-xl">
<span>Årets resultat</span>
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
{formatAmount(data.net_result)} kr
</span>
</div>
</CardContent>
</Card>
</div>
)
}
export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<BalanceSheetReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/balance-sheet?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
setLoading(false)
})
.catch(() => {
setError('Kunde inte hämta balansräkning')
setLoading(false)
})
}, [periodId, reportQs])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar balansräkning...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data för denna period.
</CardContent>
</Card>
)
}
const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01
return (
<div className="space-y-4">
<ReportExportMenu
items={[
{ format: 'pdf', href: `/api/reports/balance-sheet/pdf?${reportQs}` },
{ format: 'xlsx', href: `/api/reports/balance-sheet/xlsx?${reportQs}` },
]}
/>
{/* Assets */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tillgångar</CardTitle>
</CardHeader>
<CardContent>
<ReportSectionTable sections={data.asset_sections} onNavigateToAccount={onNavigateToAccount} />
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
<span>Summa tillgångar</span>
<span>{formatAmount(data.total_assets)} kr</span>
</div>
</CardContent>
</Card>
{/* Equity and liabilities */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Eget kapital och skulder</CardTitle>
</CardHeader>
<CardContent>
<ReportSectionTable sections={data.equity_liability_sections} onNavigateToAccount={onNavigateToAccount} />
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
<span>Summa eget kapital och skulder</span>
<span>{formatAmount(data.total_equity_liabilities)} kr</span>
</div>
</CardContent>
</Card>
{/* Balance check */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<span className="font-bold text-lg">Balanscheck</span>
{isBalanced ? (
<Badge variant="success" className="text-base px-3 py-1">
Balanserar
</Badge>
) : (
<div className="text-right">
<Badge variant="destructive" className="text-base px-3 py-1">
Balanserar ej
</Badge>
<p className="text-sm text-destructive mt-1">
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
</p>
</div>
)}
</div>
{!isBalanced && data.imbalance_diagnosis && (
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t">
{data.imbalance_diagnosis.message}
</p>
)}
</CardContent>
</Card>
</div>
)
}
export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<ResultatrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/resultatrapport?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
setLoading(false)
})
.catch(() => {
setError('Kunde inte hämta resultatrapport')
setLoading(false)
})
}, [periodId, reportQs])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.groups.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda intäkter eller kostnader i denna period.
</CardContent>
</Card>
)
}
const hasPrior = data.prior_period !== null
const colCount = 4
return (
<div className="space-y-4">
<ReportExportMenu
items={[
{ format: 'pdf', href: `/api/reports/resultatrapport/pdf?${reportQs}` },
{ format: 'xlsx', href: `/api/reports/resultatrapport/xlsx?${reportQs}` },
]}
/>
{formatLatestVouchers(data.latest_vouchers) && (
<p className="text-sm text-muted-foreground">
{LATEST_VOUCHERS_LABEL}: {formatLatestVouchers(data.latest_vouchers)}
</p>
)}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
<th
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
title={`${data.period.start} till ${data.period.end}`}
>
Innevarande
</th>
<th
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
title={hasPrior ? `${data.prior_period!.start} till ${data.prior_period!.end}` : undefined}
>
Föregående
</th>
</tr>
</thead>
<tbody>
{data.groups.map((group) => (
<React.Fragment key={group.class}>
<tr className="bg-muted/30">
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
{group.class_label}
</td>
</tr>
{group.rows.map((row) => (
<tr
key={row.account_number}
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onNavigateToAccount(row.account_number)}
>
<td className="px-4 py-1.5">
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td className="px-4 py-1.5">{row.account_name}</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.current_period)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
{hasPrior ? formatAmount(row.prior_period) : '-'}
</td>
</tr>
))}
<tr className="border-b font-medium">
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
Summa
</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_current)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
{hasPrior ? formatAmount(group.subtotal_prior) : '-'}
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="border-2">
<CardContent className="py-4">
<div className="grid gap-x-6 items-baseline grid-cols-[1fr_auto_auto]">
<span className="font-bold text-lg">Beräknat resultat</span>
<span className={`tabular-nums font-bold text-lg w-32 text-right ${data.net_result_current >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatAmount(data.net_result_current)} kr
</span>
<span className="tabular-nums text-base text-muted-foreground w-32 text-right">
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
</span>
</div>
</CardContent>
</Card>
</div>
)
}
export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
const [data, setData] = useState<BalansrapportReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const reportQs = reportQuery(periodId, dateRange)
useEffect(() => {
setLoading(true)
setError(null)
fetch(`/api/reports/balansrapport?${reportQs}`)
.then((res) => res.json())
.then((result) => {
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
setLoading(false)
})
.catch(() => {
setError('Kunde inte hämta balansrapport')
setLoading(false)
})
}, [periodId, reportQs])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar balansrapport...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.groups.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga balansposter i denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<ReportExportMenu
items={[
{ format: 'pdf', href: `/api/reports/balansrapport/pdf?${reportQs}` },
{ format: 'xlsx', href: `/api/reports/balansrapport/xlsx?${reportQs}` },
]}
/>
{formatLatestVouchers(data.latest_vouchers) && (
<p className="text-sm text-muted-foreground">
{LATEST_VOUCHERS_LABEL}: {formatLatestVouchers(data.latest_vouchers)}
</p>
)}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Ingående balans</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Förändring</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Utgående balans</th>
</tr>
</thead>
<tbody>
{data.groups.map((group) => (
<React.Fragment key={group.class}>
<tr className="bg-muted/30">
<td colSpan={5} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
{group.class_label}
</td>
</tr>
{group.rows.map((row) => (
<tr
key={row.account_number}
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onNavigateToAccount(row.account_number)}
>
<td className="px-4 py-1.5">
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td className="px-4 py-1.5">{row.account_name}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.ib)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.period_change)}</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.ub)}</td>
</tr>
))}
<tr className="border-b font-medium">
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
Summa
</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(group.subtotal_ib)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_ub)}</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="border-2">
<CardContent className="py-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Summa tillgångar</span>
<span className="tabular-nums">{formatAmount(data.total_assets_ub)} kr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Summa eget kapital, reserver, avsättningar och skulder</span>
<span className="tabular-nums">{formatAmount(data.total_equity_liabilities_ub)} kr</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Beräknat resultat (ej bokslutsjusterat)</span>
<span className="tabular-nums">{formatAmount(data.beraknat_resultat)} kr</span>
</div>
<div className="flex justify-between items-center pt-2 border-t">
<span className="font-bold text-lg">Balanscheck</span>
{data.is_balanced ? (
<Badge variant="success" className="text-base px-3 py-1">
Balanserar
</Badge>
) : (
<div className="text-right">
<Badge variant="destructive" className="text-base px-3 py-1">
Balanserar ej
</Badge>
{data.imbalance_diagnosis && (
<p className="text-sm text-destructive mt-1">
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
</p>
)}
</div>
)}
</div>
{!data.is_balanced && data.imbalance_diagnosis && (
<p className="text-sm text-muted-foreground pt-2 border-t">
{data.imbalance_diagnosis.message}
</p>
)}
</CardContent>
</Card>
</div>
)
}
function ReportSectionTable({
sections,
negate,
onNavigateToAccount,
}: {
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
negate?: boolean
onNavigateToAccount?: (account: string) => void
}) {
if (sections.length === 0) {
return <p className="text-sm text-muted-foreground">Inga poster.</p>
}
return (
<div className="space-y-3">
{sections.map((section) => (
<div key={section.title}>
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
<tbody>
{section.rows.map((row) => (
<tr
key={row.account_number}
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
onClick={onNavigateToAccount ? () => onNavigateToAccount(row.account_number) : undefined}
>
<td className="py-1 w-16"><AccountNumber number={row.account_number} name={row.account_name} /></td>
<td className="py-1">{row.account_name}</td>
<td className="py-1 text-right w-28">
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
</td>
</tr>
))}
</tbody>
</table></div>
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
<span>{section.title}</span>
<span>
{negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
</span>
</div>
</div>
))}
</div>
)
}
// Carries the selected fiscal period into the ruta drill-down rows so their
// source-verifikat query matches the report's period. Only set for yearly
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
// Skatteverket's e-service entry point for manually filing the momsdeklaration.
// Manual filing needs no connection, so this link is the default path for
// anyone who hasn't set up (or doesn't want) the direct-submission integration.
const SKATTEVERKET_MOMS_URL =
'https://www.skatteverket.se/foretag/etjansterochblanketter/etjanster/momsocharbetsgivardeklarationer'
/**
* Manual-filing affordance shown directly under the calculated momsdeklaration.
* The report is generated purely from the bookkeeping and never depends on the
* Skatteverket connection, so every user (including core builds with the
* skatteverket extension disabled) can file manually. Two paths are offered:
* an eSKD XML file to upload directly under "Deklarera via fil" (the fast path),
* and a PDF (in hela kronor) to read off if the user would rather type the
* boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML.
*/
function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) {
return (
<section>
<div className="mb-3 flex items-center gap-3 px-1">
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Lämna in själv, med fil
</h3>
<div className="h-px flex-1 bg-border/60" />
</div>
<div className="space-y-4">
<p className="text-[13px] leading-6 text-muted-foreground">
Du behöver inte vara ansluten till Skatteverket för att lämna in.
</p>
<ol className="list-decimal pl-6 space-y-1 text-sm text-muted-foreground">
<li>Ladda ner XML-filen nedan.</li>
<li>Logga in på skatteverket.se med BankID.</li>
<li>Öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.</li>
<li>Ladda upp filen, granska och signera.</li>
</ol>
<p className="text-xs text-muted-foreground">
Vill du hellre fylla i rutorna för hand laddar du ner PDF:en och skriver av
beloppen (i hela kronor).
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild className="gap-2">
<a href={xmlHref} target="_blank" rel="noopener noreferrer">
<FileCode className="h-4 w-4" />
Ladda ner fil för uppladdning (XML)
</a>
</Button>
<Button variant="outline" asChild className="gap-2">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileDown className="h-4 w-4" />
Ladda ner momsdeklaration (PDF)
</a>
</Button>
<Button variant="outline" asChild className="gap-2">
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
Öppna skatteverket.se
</a>
</Button>
</div>
</div>
</section>
)
}
/**
* "Bokför momsrapport" (issue #980): builds an editable verifikat proposal
* from the momsrapport (clearing the period's 26xx accounts to 2650/1650,
* öre gap on 3740) and books it through the ordinary journal entry form, so
* every line can be adjusted before committing. The proposal comes from
* /api/reports/vat-declaration/settlement-proposal; booking goes through
* POST /api/bookkeeping/journal-entries with source_type 'vat_settlement',
* which the declaration projection excludes, so the report above keeps
* showing the declared figures after booking.
*/
function VatBookingCard({
periodType,
year,
period,
fiscalPeriodId,
checksBlocked,
onStatus,
}: {
periodType: VatPeriodType
year: number
period: number
fiscalPeriodId?: string
/**
* True when the local pre-flight checks found ERRORs. Booking stays
* possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx
* accounts the settlement clears), but the user should know before filing.
*/
checksBlocked?: boolean
/** Lets the surrounding stepper mirror the booking state on its dot. */
onStatus?: (status: 'booked' | 'draft' | 'none') => void
}) {
const { canWrite } = useCanWrite()
const [dialogOpen, setDialogOpen] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
// Fetch outcome tagged with the key it was requested under; proposal/failed
// are derived by comparing that tag with the current key, so the effect
// never sets state synchronously (same pattern as VatDeclarationView).
const [result, setResult] = useState<{
key: string
proposal?: VatSettlementProposal
failed?: boolean
} | null>(null)
const fetchKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}`
useEffect(() => {
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
let cancelled = false
fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true })
else setResult({ key: fetchKey, proposal: json.data })
})
.catch(() => {
if (!cancelled) setResult({ key: fetchKey, failed: true })
})
return () => {
cancelled = true
}
}, [fetchKey, periodType, year, period, fiscalPeriodId])
const upToDate = result !== null && result.key === fetchKey
const proposal = upToDate ? (result.proposal ?? null) : null
const failed = upToDate && !!result.failed
const booked = proposal?.existing_entries.find((e) => e.status === 'posted')
const draft = booked ? undefined : proposal?.existing_entries.find((e) => e.status === 'draft')
const bookingStatus = booked ? 'booked' : draft ? 'draft' : 'none'
useEffect(() => {
if (upToDate && proposal) onStatus?.(bookingStatus)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [upToDate, bookingStatus])
// FormLine amounts are input strings; the proposal's numbers are already
// öre-rounded server-side, so this is display formatting, not money math.
const initialLines: FormLine[] = (proposal?.lines ?? []).map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount > 0 ? l.debit_amount.toFixed(2) : '',
credit_amount: l.credit_amount > 0 ? l.credit_amount.toFixed(2) : '',
line_description: l.line_description ?? '',
}))
return (
<div className="space-y-4">
<p className="text-[13px] leading-6 text-muted-foreground">
Skapa ett verifikat som nollställer periodens momskonton och bokför
momsen att betala eller få tillbaka på redovisningskontot. Du granskar
förslaget och kan ändra raderna innan verifikatet bokförs.
</p>
{booked && (
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
<p>
Momsen för perioden är redan bokförd:{' '}
<Link
href={`/bookkeeping/${booked.id}`}
className="underline underline-offset-2 hover:text-foreground"
>
verifikat {formatVoucher(booked)} ({formatDate(booked.entry_date)})
</Link>
. Annullera det verifikatet först om perioden behöver bokföras om.
</p>
</div>
)}
{draft && (
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
<p>
Det finns redan ett{' '}
<Link
href={`/bookkeeping/${draft.id}`}
className="underline underline-offset-2 hover:text-foreground"
>
utkast för momsen i perioden
</Link>
.
</p>
</div>
)}
{checksBlocked && (
<p className="text-[12.5px] leading-5 text-attn">
Det finns fel under steg 1, Kontrollera. Du kan bokföra momsen ändå, men
åtgärda felen innan du lämnar in.
</p>
)}
{failed ? (
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-destructive">Kunde inte hämta verifikatförslaget.</p>
<Button variant="outline" onClick={() => setRefreshKey((k) => k + 1)}>
Försök igen
</Button>
</div>
) : !upToDate ? (
<Skeleton className="h-10 w-40" />
) : proposal?.is_empty ? (
<p className="text-sm text-muted-foreground">Ingen moms att bokföra för perioden.</p>
) : (
<div className="space-y-2">
<Button
// A posted settlement blocks re-booking: the proposal re-clears the
// FULL period (it is not delta-aware), so booking twice would
// corrupt the 26xx balances. Annulling the verifikat restores them
// and re-enables the button.
disabled={!proposal || !canWrite || !!booked}
onClick={() => setDialogOpen(true)}
>
Skapa verifikat
</Button>
{!canWrite && (
<p className="text-xs text-muted-foreground">
Du har läsbehörighet och kan inte bokföra.
</p>
)}
</div>
)}
{proposal && (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A reviewed-but-unbooked proposal must survive an accidental
// backdrop click or stray Escape (same rationale as
// NewJournalEntryDialog): closing is explicit via the header X.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Bokför momsrapport</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Förslaget bygger på momsrapporten för {proposal.period_label}. Justera
datum, konton eller belopp vid behov och bokför sedan verifikatet.
</p>
{dialogOpen && (
<JournalEntryForm
bare
sourceType="vat_settlement"
initialDate={proposal.entry_date}
initialDescription={proposal.description}
initialLines={initialLines}
onCreated={() => {
setDialogOpen(false)
setRefreshKey((k) => k + 1)
}}
/>
)}
</DialogContent>
</Dialog>
)}
</div>
)
}
/** The Stegen header (concept Moms C): the filing pipeline as a clickable
* horizontal stepper with honest per-step status subs. Statutory surface,
* Swedish in both locales like the rest of the declaration. */
function VatStepper({
active,
onSelect,
errorCount,
warningCount,
ruta49,
bookingStatus,
}: {
active: number
onSelect: (step: number) => void
errorCount: number
warningCount: number
ruta49: number
bookingStatus: 'booked' | 'draft' | 'none' | null
}) {
const steps = [
{
n: 1,
label: 'Kontrollera',
sub:
errorCount > 0
? `${errorCount} fel`
: warningCount > 0
? `${warningCount} ${warningCount === 1 ? 'varning' : 'varningar'}`
: 'klart',
done: errorCount === 0,
warn: errorCount > 0,
},
{
n: 2,
label: 'Granska',
sub:
ruta49 > 0
? `${formatAmount(ruta49)} kr att betala`
: ruta49 < 0
? `${formatAmount(Math.abs(ruta49))} kr att återfå`
: 'ingen moms',
done: false,
warn: false,
},
{
n: 3,
label: 'Bokför',
sub:
bookingStatus === 'booked'
? 'bokförd'
: bookingStatus === 'draft'
? 'utkast finns'
: 'mot 2650',
done: bookingStatus === 'booked',
warn: false,
},
{ n: 4, label: 'Lämna in', sub: 'till Skatteverket', done: false, warn: false },
]
return (
<div
className="mx-auto flex w-full max-w-3xl items-center gap-3 overflow-x-auto px-1"
role="tablist"
aria-label="Momsdeklarationens steg"
>
{steps.map((step, i) => (
<React.Fragment key={step.n}>
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
<button
type="button"
role="tab"
aria-selected={active === step.n}
onClick={() => onSelect(step.n)}
className="group flex shrink-0 items-center gap-2 text-left"
>
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
step.done
? 'border-success/40 text-success'
: active === step.n
? 'border-foreground bg-foreground text-background'
: 'border-border text-muted-foreground group-hover:border-foreground/30',
)}
>
{step.done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.n}
</span>
<span className="leading-tight">
<span
className={cn(
'block text-[12.5px] transition-colors duration-150',
active === step.n
? 'font-medium'
: 'text-muted-foreground group-hover:text-foreground',
)}
>
{step.label}
</span>
{step.sub && (
<span
className={cn(
'block text-[11px] tabular-nums',
step.warn ? 'text-attn' : 'text-muted-foreground',
)}
>
{step.sub}
</span>
)}
</span>
</button>
</React.Fragment>
))}
</div>
)
}
const MONTH_NAMES = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
const QUARTER_SPANS = ['jan-mar', 'apr-jun', 'jul-sep', 'okt-dec']
export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth() + 1
const currentQuarter = Math.ceil(currentMonth / 3)
// periodType stays null until the company's VAT settings have settled, so
// the first (automatic) fetch runs against the configured momsperiod instead
// of a guessed default.
const [periodType, setPeriodType] = useState<VatPeriodType | null>(null)
const [year, setYear] = useState(currentYear)
const [period, setPeriod] = useState(currentQuarter)
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar
// year — picked inline in yearly mode. Monthly/quarterly are calendar
// periods and need no fiscal year. The period's end date rides along so
// the Skatteverket panel can target the FY-end month (broken fiscal years
// do not end in December).
const [fiscalPeriodId, setFiscalPeriodId] = useState('')
const [fiscalPeriodEnd, setFiscalPeriodEnd] = useState<string | null>(null)
// Latest fetch outcome, tagged with the fetch key it was requested under.
// loading / error / data are all derived by comparing that tag with the
// current key, so the fetch effect never sets state synchronously.
const [result, setResult] = useState<{
key: string
declaration?: VatDeclaration
error?: string
} | null>(null)
const [retryKey, setRetryKey] = useState(0)
// Stegen: which of the four pipeline steps is open. null = automatic
// (errors land on Kontrollera, otherwise Granska). A period switch resets
// to automatic so stale step choices never survive a context change.
const [chosenStep, setChosenStep] = useState<number | null>(null)
const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null)
// Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard)
// because the filing gate lives here and the worklist unmounts as soon as
// the user leaves steg 1. Tagged with the PERIOD it was requested for (see
// gapScanPeriodKey), so a korrigering refetches without the gate falling
// open in between; count === null means "not known" (scan failed), which is
// deliberately NOT the same as zero.
const [gapScan, setGapScan] = useState<{ key: string; count: number | null } | null>(null)
// Company settings drive both the momsregistrerad gate and the default
// periodicity (moms_period in Inställningar). Applied once per company the
// first time its settings settle — as a render-phase adjustment, not an
// effect. A later manual change to the picker is preserved, and a company
// switch re-applies the new company's setting. `useCompanySettings` only
// refetches when the active company changes, so this never clobbers a
// manual selection mid-session.
const { settings, isLoading: settingsLoading } = useCompanySettings()
const [appliedCompany, setAppliedCompany] = useState<string | null>(null)
const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none')
if (companyKey !== null && appliedCompany !== companyKey) {
setAppliedCompany(companyKey)
const configured = settings?.moms_period ?? 'quarterly'
setPeriodType(configured)
setPeriod(
configured === 'monthly' ? currentMonth : configured === 'quarterly' ? currentQuarter : 1,
)
}
// Settings row present and the company answered "not VAT-registered" —
// the declaration is meaningless, so the whole view is gated below.
const notVatRegistered = !settingsLoading && settings !== null && !settings.vat_registered
// Registered but never picked a redovisningsperiod (rare — onboarding
// requires it, but companies created outside that flow can miss it).
const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period
// Switching periodicity resets the period to "now" in the new unit. Done in
// the change handler (not an effect) so the auto-fetch below never sees an
// inconsistent periodType/period pair.
const handlePeriodTypeChange = (value: VatPeriodType) => {
setPeriodType(value)
setPeriod(value === 'monthly' ? currentMonth : value === 'quarterly' ? currentQuarter : 1)
}
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year.
// For yearly we pass the selected fiscal period so the API uses its actual
// bounds (handles extended/shortened years); monthly/quarterly stay calendar.
const isYearly = periodType === 'yearly'
const awaitingFiscalPeriod = isYearly && !fiscalPeriodId
const vatQueryString = () => {
const params = new URLSearchParams({
periodType: periodType ?? 'quarterly',
year: String(year),
period: String(period),
})
if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
return params.toString()
}
// The declaration loads as soon as the period is known — no manual "Hämta"
// step. fetchKey is null while a prerequisite is missing (settings pending,
// gated, or no redovisningsperiod configured); any change to it triggers a
// refetch and stale responses are discarded.
const fetchKey =
periodType === null || notVatRegistered || momsPeriodMissing || awaitingFiscalPeriod
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
// Period identity WITHOUT the retry counter, used to decide whether the gap
// scan below still speaks for what is on screen. The scan re-runs on every
// retryKey bump (each korrigering), but the last settled count stays in
// force until the new one lands: falling back to `pending` mid-korrigering
// would drop the gap finding out of `checks` for one round trip, and with
// the aggregate check silent (the tolerance case this gate exists for) the
// green "Inga fel hittades i underlaget för perioden" would render right
// above the vouchers still left in the worklist, with Skicka enabled. That
// is the exact regression being fixed, and steg 1 stays mounted across a
// korrigering, so the user would be looking straight at it. Stale-but-closed
// is the safe direction, and it matches the declaration itself, which stays
// on screen (dimmed) while the next one loads.
const gapScanPeriodKey =
periodType === null
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}`
useEffect(() => {
setChosenStep(null)
setBookingStatus(null)
}, [periodType, year, period, fiscalPeriodId])
useEffect(() => {
if (!fetchKey || periodType === null) return
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
let cancelled = false
fetch(`/api/reports/vat-declaration?${params.toString()}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
if (!res.ok || json?.error) {
setResult({
key: fetchKey,
error:
typeof json?.error === 'string' ? json.error : 'Kunde inte hämta momsdeklaration',
})
} else {
setResult({ key: fetchKey, declaration: json.data })
}
})
.catch(() => {
if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta momsdeklaration' })
})
return () => {
cancelled = true
}
}, [fetchKey, periodType, year, period, fiscalPeriodId])
// The per-verifikat gap scan runs on the same key as the declaration, so a
// korrigering (which bumps retryKey via onCorrected) re-verifies the gate
// instead of leaving it stuck on the pre-correction count.
useEffect(() => {
if (!fetchKey || periodType === null || !gapScanPeriodKey) return
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
let cancelled = false
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
.then(async (res) => {
const json = await res.json().catch(() => null)
if (cancelled) return
// count === null is a settled FAILURE, and unknown is not "inga
// brister": it becomes a visible warning row (so the banner cannot
// claim all-clear on a check that never ran) but does not block, or a
// network hiccup would lock the user out of a statutory deadline.
if (!res.ok || json?.error) setGapScan({ key: gapScanPeriodKey, count: null })
else setGapScan({ key: gapScanPeriodKey, count: (json?.data?.gaps ?? []).length })
})
.catch(() => {
if (!cancelled) setGapScan({ key: gapScanPeriodKey, count: null })
})
return () => {
cancelled = true
}
}, [fetchKey, gapScanPeriodKey, periodType, year, period, fiscalPeriodId])
// Derived fetch state: the previous declaration stays visible (dimmed)
// while the next period loads.
const upToDate = result !== null && result.key === fetchKey
const data = result?.declaration ?? null
const error = upToDate ? (result.error ?? null) : null
const loading = fetchKey !== null && !upToDate
// Local pre-flight checks on the calculated declaration. Computed here (not
// in SkatteverketPanel) so every user sees them: they gate direct submission
// but concern manual filers just as much.
//
// The per-verifikat gap scan is folded in BEFORE anything derives from the
// list. The aggregate checks compare period totals with a tolerance that
// scales with period size, so they can stay silent while individual
// verifikat still miss their basbelopp: reading them alone let the green
// "Inga fel hittades i underlaget för perioden" render directly above the
// worklist of those verifikat, with Skicka enabled. Banner, stegen and the
// send gate now all read this ONE array. The three scan states are kept
// apart: only a settled count of zero is allowed to mean "inga brister".
const gapScanSettled = gapScan !== null && gapScan.key === gapScanPeriodKey
const rcBasisScan: RcBasisGapScan = !gapScanSettled
? { status: 'pending' }
: gapScan.count === null
? { status: 'unavailable' }
: { status: 'scanned', gapCount: gapScan.count }
// The declaration carries the 2645/2647 totals (rcInputAccountTotals), so
// RC_INPUT_VAT_MISMATCH compares rutor 30-32 against the reverse-charge INPUT
// accounts instead of the ruta 48 aggregate. Without them, 50 000 kr of
// fiktiv utgående moms with nothing on 2645 sits silently behind 60 000 kr of
// ordinary 2641 and the user pays in moms they were entitled to deduct.
// rcInputTotalsFromDeclaration returns undefined (not an empty map) when a
// response predates the field, which keeps the fallback honest.
const checks = data
? withRcBasisGapFindings(
runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)),
rcBasisScan,
)
: []
const checksBlocked = isFilingBlocked(checks)
const errorCount = checks.filter((c) => c.status === 'ERROR').length
const warningCount = checks.filter((c) => c.status === 'WARNING').length
// Latch the automatic landing step once per period, as a render-phase
// adjustment when the period's declaration first settles. Deriving it live
// from checksBlocked navigated the user away mid-work: the refetch after a
// korrigering can clear the aggregate error while the Kontrollera worklist
// still holds broken vouchers, and the view would jump to Granska under
// their feet. The period-change effect below resets chosenStep to null,
// which re-arms this latch for the next period. It also waits for the gap
// scan: latching on the aggregate alone landed the user on Granska while
// steg 1 still held a blocking worklist.
if (chosenStep === null && upToDate && data && !error && gapScanSettled) {
setChosenStep(checksBlocked ? 1 : 2)
}
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
// Settings not settled yet — the picker defaults and the gate both depend
// on them, so hold the whole view in a skeleton.
// The standalone page renders its own PageHeader (FocusedReport passes the
// title and skips its own), so the H1 must survive the gated/loading
// states too: each early return carries the action-less header.
const bareHeader = pageTitle ? <PageHeader title={pageTitle} /> : null
if (settingsLoading || periodType === null) {
return (
<div className="space-y-8">
{bareHeader}
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
</div>
)
}
if (notVatRegistered) {
return (
<div className="space-y-8">
{bareHeader}
<EmptyState
icon={Percent}
title="Företaget är inte momsregistrerat"
description="Momsdeklarationen bygger på företagets skatteinställningar. Om företaget är momsregistrerat anger du momsregistrering och redovisningsperiod i inställningarna, så visas deklarationen här."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
</div>
)
}
// Registered but no redovisningsperiod picked: block instead of guessing.
// A declaration rendered (and submittable via panelen) for the wrong
// period type is a compliance hazard, not a convenience.
if (momsPeriodMissing) {
return (
<div className="space-y-8">
{bareHeader}
<EmptyState
icon={Percent}
title="Redovisningsperiod för moms saknas"
description="Företaget är momsregistrerat men ingen redovisningsperiod (månad, kvartal eller helår) är vald. Ange den i skatteinställningarna så visas deklarationen för rätt period."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
</div>
)
}
// Year and concrete period fused into ONE chip: reverse-chronological
// "Kvartal 2 2026", "Kvartal 1 2026", ... across the last five years, so
// switching period never needs two pickers. Yearly keeps the FyPicker,
// which is already a fused räkenskapsår chip.
const fusedPeriodItems: { id: string; label: string; annotation?: string }[] = []
if (!isYearly) {
for (let y = currentYear; y > currentYear - 5; y--) {
if (periodType === 'quarterly') {
for (let q = 4; q >= 1; q--) {
fusedPeriodItems.push({
id: `${y}:${q}`,
label: `Kvartal ${q} ${y}`,
annotation: QUARTER_SPANS[q - 1],
})
}
} else {
for (let m = 12; m >= 1; m--) {
fusedPeriodItems.push({ id: `${y}:${m}`, label: `${MONTH_NAMES[m - 1]} ${y}` })
}
}
}
}
const fusedLabel =
periodType === 'quarterly' ? `Kvartal ${period} ${year}` : `${MONTH_NAMES[period - 1]} ${year}`
return (
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
<div className="space-y-8">
{/* Standalone page: the title row carries the primary action (locked
convention 9), so Exportera sits beside the H1 and the period chips
get their own row below. XML and PDF live in "Lämna in": they are
filing artifacts, not report exports. */}
{pageTitle && (
<PageHeader
title={pageTitle}
action={
<ReportExportMenu
variant="default"
items={[
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
]}
/>
}
/>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Cadence lives behind a settings-style "Period" chip: the
concrete period chip next to it already shows the cadence
("Kvartal 2" implies quarterly, "Räkenskapsår ..." yearly). */}
<ContextPicker
items={[
{ id: 'monthly', label: 'Månadsvis' },
{ id: 'quarterly', label: 'Kvartalsvis' },
{ id: 'yearly', label: 'Årsvis' },
]}
value={periodType}
onChange={(value) => handlePeriodTypeChange(value as VatPeriodType)}
triggerLabel="Period"
ariaLabel="Periodicitet"
/>
{isYearly ? (
// Annual VAT covers a räkenskapsår: picked here, not a
// calendar year.
<FyPicker
value={fiscalPeriodId || null}
onChange={(id, fp) => {
setFiscalPeriodId(id || '')
setFiscalPeriodEnd(fp?.period_end ?? null)
}}
includeAllOption={false}
hideFuturePeriods
/>
) : (
<ContextPicker
items={fusedPeriodItems}
value={`${year}:${period}`}
onChange={(id) => {
const [y, p] = id.split(':').map(Number)
setYear(y)
setPeriod(p)
}}
triggerLabel={fusedLabel}
ariaLabel="Redovisningsperiod"
/>
)}
{!pageTitle && (
<ReportExportMenu
variant="default"
items={[
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
]}
/>
)}
</div>
{error && (
<Card>
<CardContent className="flex flex-col items-center p-8 text-center">
<AlertCircle className="mb-2 h-6 w-6 text-destructive" />
<p className="mb-4 text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={() => setRetryKey((k) => k + 1)}>
Försök igen
</Button>
</CardContent>
</Card>
)}
{!error && (awaitingFiscalPeriod || (loading && !data)) && (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)}
{data && !awaitingFiscalPeriod && (
<div
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
>
{/* Stegen (concept): the filing pipeline as a horizontal stepper —
kontrollera, granska, bokför, lämna in — showing one step's
content at a time. Errors land on step 1, otherwise Granska. */}
<VatStepper
active={activeStep}
onSelect={setChosenStep}
errorCount={errorCount}
warningCount={warningCount}
ruta49={data.rutor.ruta49}
bookingStatus={bookingStatus}
/>
{activeStep === 1 && (
<section className="mx-auto max-w-3xl space-y-3">
<VatChecksCard
checks={checks}
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
onCorrected={() => setRetryKey((k) => k + 1)}
/>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(2)}>
Nästa: Granska deklarationen →
</Button>
</div>
</section>
)}
{activeStep === 2 && (
<section className="space-y-3">
<div className="mx-auto max-w-2xl">
<div className="flex flex-wrap items-baseline justify-between gap-3 px-1">
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Momsdeklaration · {data.period.start} till {data.period.end}
</h3>
<span className="text-[11.5px] tabular-nums text-muted-foreground">
{data.invoiceCount} fakturor · {data.transactionCount} transaktioner
</span>
</div>
<div className="mt-4 space-y-8">
<div>
{/* Utgående moms */}
<div>
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Utgående moms (försäljning)
</h3>
<Table>
<TableBody>
{data.rutor.ruta05 > 0 && (
<VatRutaRow
ruta="05"
label="Momspliktig försäljning"
amount={data.rutor.ruta05}
baseAmount={0}
periodType={periodType}
year={year}
period={period}
/>
)}
<VatRutaRow
ruta="10"
label="Utgående moms 25%"
amount={data.rutor.ruta10}
baseAmount={data.breakdown.invoices.base25}
periodType={periodType}
year={year}
period={period}
/>
<VatRutaRow
ruta="11"
label="Utgående moms 12%"
amount={data.rutor.ruta11}
baseAmount={data.breakdown.invoices.base12}
periodType={periodType}
year={year}
period={period}
/>
<VatRutaRow
ruta="12"
label="Utgående moms 6%"
amount={data.rutor.ruta12}
baseAmount={data.breakdown.invoices.base6}
periodType={periodType}
year={year}
period={period}
/>
<VatRutaRow
ruta="39"
label="Tjänster EU (omvänd skattskyldighet)"
amount={0}
baseAmount={data.rutor.ruta39}
noVat
periodType={periodType}
year={year}
period={period}
/>
<VatRutaRow
ruta="40"
label="Export utanför EU"
amount={0}
baseAmount={data.rutor.ruta40}
noVat
periodType={periodType}
year={year}
period={period}
/>
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa utgående moms</td>
<td className="py-2 text-right tabular-nums">
{formatAmount(
data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 +
data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 +
data.rutor.ruta60 + data.rutor.ruta61 + data.rutor.ruta62
)} kr
</td>
</tr>
</tfoot>
</Table>
{/* Omvänd skattskyldighet (inköp) */}
{(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 ||
data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && (
<>
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Omvänd skattskyldighet (inköp)
</h3>
<Table>
<TableBody>
<VatRutaRow ruta="20" label="Inköp av varor från annat EU-land" amount={0} baseAmount={data.rutor.ruta20} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="21" label="Inköp av tjänster från annat EU-land" amount={0} baseAmount={data.rutor.ruta21} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="22" label="Inköp av tjänster utanför EU" amount={0} baseAmount={data.rutor.ruta22} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="23" label="Inköp av varor i Sverige" amount={0} baseAmount={data.rutor.ruta23} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="24" label="Övriga inköp av tjänster i Sverige" amount={0} baseAmount={data.rutor.ruta24} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="30" label="Utgående moms 25% (omvänd)" amount={data.rutor.ruta30} baseAmount={0} periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="31" label="Utgående moms 12% (omvänd)" amount={data.rutor.ruta31} baseAmount={0} periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="32" label="Utgående moms 6% (omvänd)" amount={data.rutor.ruta32} baseAmount={0} periodType={periodType} year={year} period={period} />
</TableBody>
</Table>
</>
)}
{/* Moms vid import */}
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
<>
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Moms vid import
</h3>
<Table>
<TableBody>
<VatRutaRow ruta="50" label="Beskattningsunderlag vid import" amount={0} baseAmount={data.rutor.ruta50} noVat periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="60" label="Utgående moms 25% import" amount={data.rutor.ruta60} baseAmount={0} periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="61" label="Utgående moms 12% import" amount={data.rutor.ruta61} baseAmount={0} periodType={periodType} year={year} period={period} />
<VatRutaRow ruta="62" label="Utgående moms 6% import" amount={data.rutor.ruta62} baseAmount={0} periodType={periodType} year={year} period={period} />
</TableBody>
</Table>
</>
)}
</div>
</div>
<div>
{/* Ingående moms */}
<div>
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
Ingående moms (avdragsgill)
</h3>
<Table>
<TableBody>
<VatRutaRow
ruta="48"
label="Ingående moms att dra av"
amount={data.rutor.ruta48}
baseAmount={0}
periodType={periodType}
year={year}
period={period}
/>
{data.breakdown.transactions.ruta48 > 0 && (
<tr className="text-muted-foreground">
<td className="py-1 pl-6 text-xs">- från transaktioner</td>
<td className="py-1 text-right text-xs">
{formatAmount(data.breakdown.transactions.ruta48)} kr
</td>
</tr>
)}
{data.breakdown.receipts.ruta48 > 0 && (
<tr className="text-muted-foreground">
<td className="py-1 pl-6 text-xs">- från kvitton</td>
<td className="py-1 text-right text-xs">
{formatAmount(data.breakdown.receipts.ruta48)} kr
</td>
</tr>
)}
</TableBody>
<tfoot>
<tr className="border-t font-medium">
<td className="py-2">Summa ingående moms</td>
<td className="py-2 text-right tabular-nums">{formatAmount(data.rutor.ruta48)} kr</td>
</tr>
</tfoot>
</Table>
</div>
</div>
</div>
{/* Ruta 49 as the emphasized document foot (concept skv-foot). */}
<div className="mt-8 flex items-baseline gap-3 border-t-2 border-foreground/80 pt-3">
<span className="font-mono text-xs text-muted-foreground">49</span>
<span className="flex-1 text-sm font-medium">
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
</span>
<span className="text-sm font-semibold tabular-nums">
{formatAmount(Math.abs(data.rutor.ruta49))} kr
</span>
</div>
</div>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(3)}>
Nästa: Bokför momsen →
</Button>
</div>
</section>
)}
{activeStep === 3 && (
<section className="mx-auto max-w-3xl space-y-3">
<VatBookingCard
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
checksBlocked={checksBlocked}
onStatus={setBookingStatus}
/>
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
Nästa: Lämna in →
</Button>
</div>
</section>
)}
{activeStep === 4 && (
<section className="mx-auto max-w-3xl space-y-8">
<SkatteverketPanel
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
fiscalYearEnd={
isYearly && fiscalPeriodEnd
? {
year: Number(fiscalPeriodEnd.slice(0, 4)),
month: Number(fiscalPeriodEnd.slice(5, 7)),
}
: undefined
}
hasData={data !== null}
localBlocked={checksBlocked}
/>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
/>
</section>
)}
</div>
)}
{/* Skatteverket integration panel for the no-data states (fetch error,
empty period): with data it lives inside steg 4. Hidden while the
räkenskapsår for helårsmoms is unresolved, so its actions can never
target an unconfirmed period. */}
{!awaitingFiscalPeriod && !data && (
<SkatteverketPanel
periodType={periodType}
year={year}
period={period}
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
fiscalYearEnd={
isYearly && fiscalPeriodEnd
? {
year: Number(fiscalPeriodEnd.slice(0, 4)),
month: Number(fiscalPeriodEnd.slice(5, 7)),
}
: undefined
}
hasData={data !== null}
localBlocked={checksBlocked}
/>
)}
</div>
</VatDrillContext.Provider>
)
}
function makeVatFetcher(
ruta: string,
periodType: VatPeriodType,
year: number,
period: number,
fiscalPeriodId?: string,
): ReportSourceFetcher {
return async () => {
const params = new URLSearchParams({
periodType,
year: String(year),
period: String(period),
})
// Yearly drill-down resolves against the räkenskapsår, matching the report.
if (periodType === 'yearly' && fiscalPeriodId) {
params.set('fiscal_period_id', fiscalPeriodId)
}
const res = await fetch(
`/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}`
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat')
const lines: ReportSourceLine[] = json.data?.lines || []
return { lines, next_cursor: json.data?.next_cursor ?? null }
}
}
function VatRutaRow({
ruta,
label,
amount,
baseAmount,
noVat,
periodType,
year,
period,
}: {
ruta: string
label: string
amount: number
baseAmount: number
noVat?: boolean
periodType?: VatPeriodType
year?: number
period?: number
}) {
const { fiscalPeriodId } = React.useContext(VatDrillContext)
const canDrill = periodType !== undefined && year !== undefined && period !== undefined
const fetcher = React.useMemo(
() => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null),
[canDrill, ruta, periodType, year, period, fiscalPeriodId]
)
// Hooks must be called unconditionally: provide a noop fetcher when drill
// is disabled. The early-return for zero rows lives below the hooks.
const expansion = useReportRowExpansion(
fetcher ?? (async () => ({ lines: [], next_cursor: null })),
`vat-${ruta}`
)
// Don't show rows with zero values
if (baseAmount === 0 && amount === 0) return null
return (
<>
<tr className="border-b">
<td className="py-2">
{canDrill && (
<span className="inline-block align-middle mr-1">
<expansion.Toggle />
</span>
)}
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
</td>
<td className="py-2 text-right tabular-nums">{noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`}</td>
</tr>
{!noVat && baseAmount > 0 && (
<tr className="text-muted-foreground">
<td className="py-1 pl-6 text-xs">Underlag</td>
<td className="py-1 text-right text-xs tabular-nums">{formatAmount(baseAmount)} kr</td>
</tr>
)}
{canDrill && <expansion.Panel colSpan={2} />}
</>
)
}
interface SupplierLedgerData {
ledger: {
entries: {
supplier_id: string
supplier_name: string
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
unconverted_fx_count: number
}
reconciliation: {
supplier_ledger_total: number
account_2440_balance: number
difference: number
is_reconciled: boolean
unconverted_fx_count: number
} | null
}
// Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default:
// toISOString() is UTC and rolls the date over an hour early in Sweden.
function localIsoDate(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
// Shared "Per datum" control + export menu header for the two reskontra views
// (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it.
function ReskontraToolbar({
asOfDate,
onAsOfDateChange,
inputId,
exportBase,
}: {
asOfDate: string
onAsOfDateChange: (date: string) => void
inputId: string
exportBase: string
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<Label htmlFor={inputId} className="text-xs text-muted-foreground">
Per datum
</Label>
<Input
id={inputId}
type="date"
value={asOfDate}
onChange={(e) => {
if (e.target.value) onAsOfDateChange(e.target.value)
}}
className="w-40"
/>
</div>
<ReportExportMenu
items={[
{ format: 'pdf', href: `${exportBase}/pdf?as_of_date=${asOfDate}` },
{ format: 'xlsx', href: `${exportBase}/xlsx?as_of_date=${asOfDate}` },
]}
/>
</div>
)
}
export function SupplierLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SupplierLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [asOfDate, setAsOfDate] = useState(localIsoDate)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta leverantörsreskontra')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId, asOfDate])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar leverantörsreskontra...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || !data.ledger) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data tillgänglig.
</CardContent>
</Card>
)
}
const { ledger, reconciliation } = data
return (
<div className="space-y-4">
<ReskontraToolbar
asOfDate={asOfDate}
onAsOfDateChange={setAsOfDate}
inputId="supplier-ledger-as-of"
exportBase="/api/reports/supplier-ledger"
/>
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
{ledger.unconverted_fx_count > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
{/* Aging table */}
{ledger.entries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ålderfördelning per leverantör</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2">Leverantör</th>
<th className="py-2 text-right">Ej förfallet</th>
<th className="py-2 text-right">1-30 dagar</th>
<th className="py-2 text-right">31-60 dagar</th>
<th className="py-2 text-right">61-90 dagar</th>
<th className="py-2 text-right">90+ dagar</th>
<th className="py-2 text-right font-semibold">Totalt</th>
</tr>
</thead>
<tbody>
{ledger.entries.map((entry) => (
<SupplierLedgerRow key={entry.supplier_id} entry={entry} />
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td className="py-2"></td>
<td className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table></div>
</CardContent>
</Card>
)}
{/* Reconciliation */}
{reconciliation && (
<Card className="border-2">
<CardHeader>
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Leverantörsreskontra (summa utestående)</span>
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2 space-y-2">
{reconciliation.is_reconciled ? (
<Badge variant="success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
{reconciliation.unconverted_fx_count > 0 && (
<p className="text-xs text-muted-foreground">
{reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
function makeSupplierFetcher(supplierId: string): ReportSourceFetcher {
return async () => {
const res = await fetch(
`/api/reports/supplier-ledger/supplier/${encodeURIComponent(supplierId)}/invoices`
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta leverantörsfakturor')
const lines: ReportSourceLine[] = json.data?.lines || []
return { lines, next_cursor: json.data?.next_cursor ?? null }
}
}
function SupplierLedgerRow({
entry,
}: {
entry: {
supplier_id: string
supplier_name: string
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}
}) {
const fetcher = React.useMemo(
() => makeSupplierFetcher(entry.supplier_id),
[entry.supplier_id]
)
const { Toggle, Panel } = useReportRowExpansion(fetcher, `sup-${entry.supplier_id}`)
return (
<>
<tr className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="py-2"><Toggle /></td>
<td className="py-2">{entry.supplier_name}</td>
<td className="py-2 text-right tabular-nums">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
<td className="py-2 text-right tabular-nums">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
<td className="py-2 text-right tabular-nums">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
<td className="py-2 text-right tabular-nums">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
<td className="py-2 text-right tabular-nums text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
<td className="py-2 text-right tabular-nums font-semibold">{formatAmount(entry.total_outstanding)}</td>
</tr>
<Panel colSpan={8} />
</>
)
}
// --- General Ledger (Huvudbok) ---
interface GeneralLedgerData {
accounts: {
account_number: string
account_name: string
opening_balance: number
lines: {
date: string
voucher_series: string
voucher_number: number
journal_entry_id: string
description: string
source_type: string
debit: number
credit: number
balance: number
dimensions?: Record<string, string>
}[]
closing_balance: number
total_debit: number
total_credit: number
}[]
period: { start: string; end: string }
}
// Stable default: an inline `= {}` would change identity every render and
// re-trigger the fetch effect for callers that omit the prop.
const EMPTY_DATE_RANGE: DateRangeValue = {}
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null, dateRange = EMPTY_DATE_RANGE }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null; dateRange?: DateRangeValue }) {
const [data, setData] = useState<GeneralLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [accountFrom, setAccountFrom] = useState('')
const [accountTo, setAccountTo] = useState('')
const fetchData = useCallback(async (fromOverride?: string, toOverride?: string) => {
const from = fromOverride ?? accountFrom
const to = toOverride ?? accountTo
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ period_id: periodId })
if (from) params.set('account_from', from)
if (to) params.set('account_to', to)
if (dateRange.fromDate) params.set('from_date', dateRange.fromDate)
if (dateRange.toDate) params.set('to_date', dateRange.toDate)
if (dimensionFilter) {
params.set('dim_no', dimensionFilter.dimNo)
params.set('dim_code', dimensionFilter.code)
}
const res = await fetch(`/api/reports/general-ledger?${params}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta huvudbok')
} finally {
setLoading(false)
}
}, [periodId, accountFrom, accountTo, dimensionFilter, dateRange])
// When initialAccountFilter changes (drill-down from another report), apply it
useEffect(() => {
if (initialAccountFilter) {
setAccountFrom(initialAccountFilter)
setAccountTo(initialAccountFilter)
fetchData(initialAccountFilter, initialAccountFilter)
} else {
fetchData()
}
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar huvudbok...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.accounts.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda verifikationer i denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
{/* Account range filter */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Konto från</Label>
<input
type="text"
value={accountFrom}
onChange={(e) => setAccountFrom(e.target.value)}
placeholder="t.ex. 1510"
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<Label>Konto till</Label>
<input
type="text"
value={accountTo}
onChange={(e) => setAccountTo(e.target.value)}
placeholder="t.ex. 1519"
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<Button onClick={() => fetchData()} variant="outline">
Filtrera
</Button>
</div>
</CardContent>
</Card>
{data.period.start && (
<p className="text-sm text-muted-foreground">
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
</p>
)}
{data.accounts.map((account) => (
<Card key={account.account_number}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
<AccountNumber number={account.account_number} name={account.account_name} showName />
</CardTitle>
<span className="text-sm text-muted-foreground">
IB: {formatAmount(account.opening_balance)} kr
</span>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-24 text-right">Debet</th>
<th className="py-2 w-24 text-right">Kredit</th>
<th className="py-2 w-28 text-right">Saldo</th>
</tr>
</thead>
<tbody>
{account.lines.map((line, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 font-mono text-xs">
<Link
href={`/bookkeeping/${line.journal_entry_id}`}
className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground transition-colors"
>
{formatVoucher(line)}
</Link>
</td>
<td className="py-1.5">{formatDate(line.date)}</td>
<td className="py-1.5 max-w-[240px]">
<span className="truncate block">{line.description}</span>
{line.dimensions && Object.keys(line.dimensions).length > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{Object.entries(line.dimensions)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, code]) => code)
.join(' · ')}
</span>
)}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
<td className="py-1.5 text-right font-mono tabular-nums">{formatAmount(line.balance)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
<td className="py-2 text-right">{formatAmount(account.total_debit)}</td>
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
</tr>
</tfoot>
</table></div>
</CardContent>
</Card>
))}
</div>
)
}
// --- Journal Register (Grundbok) ---
interface JournalRegisterData {
entries: {
voucher_series: string
voucher_number: number
date: string
description: string
source_type: string
status: string
lines: {
account_number: string
account_name: string
debit: number
credit: number
}[]
total_debit: number
total_credit: number
}[]
total_entries: number
total_debit: number
total_credit: number
period: { start: string; end: string }
}
export function JournalRegisterView({ periodId }: { periodId: string }) {
const [data, setData] = useState<JournalRegisterData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedEntries, setExpandedEntries] = useState<Set<number>>(new Set())
const fetchData = async () => {
setLoading(true)
setError(null)
setExpandedEntries(new Set())
try {
const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta grundbok')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
const toggleEntry = (index: number) => {
setExpandedEntries((prev) => {
const next = new Set(prev)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
return next
})
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar grundbok...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.entries.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda verifikationer i denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
{data.period.start && (
<p className="text-sm text-muted-foreground">
Period: {data.period.start}: {data.period.end} | {data.total_entries} verifikationer
</p>
)}
<Card>
<CardHeader>
<CardTitle>Grundbok (registreringsordning)</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-24">Typ</th>
<th className="py-2 w-24 text-right">Debet</th>
<th className="py-2 w-24 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry, index) => {
const isExpanded = expandedEntries.has(index)
const isReversed = entry.status === 'reversed'
return (
<React.Fragment key={index}>
<tr
className={`border-b cursor-pointer hover:bg-muted/50 ${isReversed ? 'line-through opacity-60' : ''}`}
onClick={() => toggleEntry(index)}
>
<td className="py-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</td>
<td className="py-2 font-mono text-xs">
{formatVoucher(entry)}
</td>
<td className="py-2">{formatDate(entry.date)}</td>
<td className="py-2">
{entry.description}
{isReversed && (
<Badge variant="outline" className="ml-2 text-xs">Makulerad</Badge>
)}
</td>
<td className="py-2 text-xs text-muted-foreground">{entry.source_type}</td>
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_debit)}</td>
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_credit)}</td>
</tr>
{isExpanded && entry.lines.map((line, lineIndex) => (
<tr key={`${index}-${lineIndex}`} className="bg-muted/30 border-b last:border-0">
<td></td>
<td></td>
<td className="py-1"><AccountNumber number={line.account_number} name={line.account_name} size="sm" /></td>
<td className="py-1 text-muted-foreground">{line.account_name}</td>
<td></td>
<td className="py-1 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
</tr>
))}
</React.Fragment>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={5} className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(data.total_debit)}</td>
<td className="py-2 text-right">{formatAmount(data.total_credit)}</td>
</tr>
</tfoot>
</table></div>
</CardContent>
</Card>
</div>
)
}
// --- AR Ledger (Kundreskontra) ---
interface ARLedgerData {
ledger: {
entries: {
customer_id: string
customer_name: string
invoices: {
invoice_id: string
invoice_number: string
invoice_date: string
due_date: string
total: number
paid_amount: number
outstanding: number
outstanding_sek: number | null
days_overdue: number
currency: string
}[]
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
unconverted_fx_count: number
}
reconciliation: {
ar_ledger_total: number
account_1510_balance: number
difference: number
is_reconciled: boolean
unconverted_fx_count: number
} | null
}
// Inner expansion row component for AR ledger.
// Fetches per-customer invoices (with journal_entry_id) and renders each as a
// link to /bookkeeping/[id] when posted, /invoices/[id] when still draft.
function ARCustomerInvoiceRows({
customerId,
invoices,
}: {
customerId: string
invoices: {
invoice_id: string
invoice_number: string
invoice_date: string
due_date: string
total: number
paid_amount: number
outstanding: number
outstanding_sek: number | null
days_overdue: number
currency: string
}[]
}) {
// ARCustomerInvoiceRows is mounted lazily: only when a customer is
// expanded, so initial state matches "still loading" and resets on
// unmount. No synchronous setState in the effect is needed.
const [enriched, setEnriched] = useState<Record<string, { journal_entry_id: string; voucher_series: string; voucher_number: number } | undefined>>({})
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`)
.then((r) => r.json())
.then((json) => {
if (cancelled) return
const map: typeof enriched = {}
for (const line of json.data?.lines || []) {
if (line.invoice_id && line.journal_entry_id) {
map[line.invoice_id] = {
journal_entry_id: line.journal_entry_id,
voucher_series: line.voucher_series,
voucher_number: line.voucher_number,
}
}
}
setEnriched(map)
})
.catch(() => { /* fail silently; rows still render without verifikat link */ })
.finally(() => { if (!cancelled) setLoaded(true) })
return () => { cancelled = true }
}, [customerId])
const loading = !loaded
return (
<>
{invoices.map((inv) => {
const entry = enriched[inv.invoice_id]
const targetHref = entry?.journal_entry_id
? `/bookkeeping/${entry.journal_entry_id}`
: `/invoices/${inv.invoice_id}`
return (
<tr key={inv.invoice_id} className="bg-muted/30 border-b last:border-0">
<td></td>
<td className="py-1 text-xs" colSpan={2}>
<Link href={targetHref} className="font-mono hover:underline underline-offset-4">
{inv.invoice_number || '(utkast)'}
</Link>
{entry && (
<span className="ml-2 text-muted-foreground font-mono">
{formatVoucher(entry)}
</span>
)}
<span className="text-muted-foreground ml-2 tabular-nums">{formatDate(inv.invoice_date)}</span>
<span className="text-muted-foreground ml-2 tabular-nums">förfaller {formatDate(inv.due_date)}</span>
</td>
<td className="py-1 text-right text-xs text-muted-foreground" colSpan={2}>
{inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
</td>
<td className="py-1 text-right text-xs text-muted-foreground">
{inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)} ${inv.currency}` : ''}
</td>
<td></td>
<td className="py-1 text-right text-xs font-medium tabular-nums">
{formatAmount(inv.outstanding)} {inv.currency}
</td>
</tr>
)
})}
{loading && (
<tr className="bg-muted/30">
<td></td>
<td colSpan={7} className="py-1 text-[10px] text-muted-foreground">Letar verifikat…</td>
</tr>
)}
</>
)
}
export function ARLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<ARLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set())
const [asOfDate, setAsOfDate] = useState(localIsoDate)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta kundreskontra')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId, asOfDate])
const toggleCustomer = (customerId: string) => {
setExpandedCustomers((prev) => {
const next = new Set(prev)
if (next.has(customerId)) {
next.delete(customerId)
} else {
next.add(customerId)
}
return next
})
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar kundreskontra...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || !data.ledger) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data tillgänglig.
</CardContent>
</Card>
)
}
const { ledger, reconciliation } = data
return (
<div className="space-y-4">
<ReskontraToolbar
asOfDate={asOfDate}
onAsOfDateChange={setAsOfDate}
inputId="ar-ledger-as-of"
exportBase="/api/reports/ar-ledger"
/>
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
{ledger.unconverted_fx_count > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
{/* Aging table with expandable invoice details */}
{ledger.entries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ålderfördelning per kund</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="py-2 w-8"></th>
<th className="py-2">Kund</th>
<th className="py-2 text-right">Ej förfallet</th>
<th className="py-2 text-right">1-30 dagar</th>
<th className="py-2 text-right">31-60 dagar</th>
<th className="py-2 text-right">61-90 dagar</th>
<th className="py-2 text-right">90+ dagar</th>
<th className="py-2 text-right font-semibold">Totalt</th>
</tr>
</thead>
<tbody>
{ledger.entries.map((entry) => {
const isExpanded = expandedCustomers.has(entry.customer_id)
return (
<React.Fragment key={entry.customer_id}>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => toggleCustomer(entry.customer_id)}
>
<td className="py-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</td>
<td className="py-2">{entry.customer_name}</td>
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
<td className="py-2 text-right text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
</tr>
{isExpanded && (
<ARCustomerInvoiceRows
customerId={entry.customer_id}
invoices={entry.invoices}
/>
)}
</React.Fragment>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td className="py-2"></td>
<td className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table></div>
</CardContent>
</Card>
)}
{/* Reconciliation */}
{reconciliation && (
<Card className="border-2">
<CardHeader>
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Kundreskontra (summa utestående)</span>
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2 space-y-2">
{reconciliation.is_reconciled ? (
<Badge variant="success">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
{reconciliation.unconverted_fx_count > 0 && (
<p className="text-xs text-muted-foreground">
{reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
// --- Resultat per projekt/kostnadsställe (dimension P&L matrix) ---
export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) {
// Loading is DERIVED (result key ≠ current query string) instead of a
// setState at effect start: keeps react-hooks/set-state-in-effect clean
// and is race-safe when the pivot/date changes mid-flight.
const [result, setResult] = useState<{
qs: string
data: DimensionPnlReport | null
error: string | null
} | null>(null)
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
const [dimNo, setDimNo] = useState('6')
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
// Registered dimensions for the pivot picker (best-effort; the report
// defaults to projekt if the registry read fails).
useEffect(() => {
fetch('/api/dimensions')
.then((res) => res.json())
.then((payload) => {
if (Array.isArray(payload.data)) {
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name })))
}
})
.catch(() => {})
}, [])
useEffect(() => {
let cancelled = false
fetch(`/api/reports/dimension-pnl?${reportQs}`)
.then((res) => res.json())
.then((payload) => {
if (cancelled) return
if (payload.error) {
setResult({
qs: reportQs,
data: null,
error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten',
})
} else {
setResult({ qs: reportQs, data: payload.data, error: null })
}
})
.catch(() => {
if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' })
})
return () => {
cancelled = true
}
}, [reportQs])
const loading = result?.qs !== reportQs
const error = loading ? null : result?.error ?? null
const data = loading ? null : result?.data ?? null
const pivotPicker = dims.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
{dims.map((d) => {
const active = String(d.sie_dim_no) === dimNo
return (
<button
key={d.sie_dim_no}
type="button"
onClick={() => setDimNo(String(d.sie_dim_no))}
className={
active
? 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-secondary border-border text-foreground'
: 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
}
>
{d.name}
</button>
)
})}
</div>
)
if (loading) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar rapport...
</CardContent>
</Card>
</div>
)
}
if (error) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
</div>
)
}
if (!data || data.groups.length === 0) {
return (
<div className="space-y-4">
{pivotPicker}
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga taggade intäkter eller kostnader i denna period.
</CardContent>
</Card>
</div>
)
}
const columnLabel = (c: DimensionPnlReport['columns'][number]) =>
c.code === null ? '(Utan dimension)' : c.code
const colCount = 2 + data.columns.length + 1
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
{pivotPicker || <span />}
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/dimension-pnl/xlsx?${reportQs}` }]} />
</div>
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
{data.columns.map((c, i) => (
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
{columnLabel(c)}
</th>
))}
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Totalt</th>
</tr>
</thead>
<tbody>
{data.groups.map((group) => (
<React.Fragment key={group.class}>
<tr className="bg-muted/30">
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
{group.class_label}
</td>
</tr>
{group.rows.map((row) => (
<tr key={row.account_number} className="border-b last:border-0">
<td className="px-4 py-1.5">
<AccountNumber number={row.account_number} name={row.account_name} />
</td>
<td className="px-4 py-1.5">{row.account_name}</td>
{row.values.map((v, i) => (
<td key={i} className="px-4 py-1.5 text-right tabular-nums">
{Math.abs(v) >= 0.005 ? formatAmount(v) : ''}
</td>
))}
<td className="px-4 py-1.5 text-right tabular-nums font-medium">{formatAmount(row.total)}</td>
</tr>
))}
<tr className="border-b font-medium">
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
Summa
</td>
{group.subtotals.map((v, i) => (
<td key={i} className="px-4 py-1.5 text-right tabular-nums">{formatAmount(v)}</td>
))}
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_total)}</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card className="border-2">
<CardContent className="py-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
<tr>
<td className="px-4 font-bold text-lg">Beräknat resultat</td>
<td className="px-4" />
{data.net_per_column.map((v, i) => (
<td key={i} className={`px-4 text-right tabular-nums font-semibold w-32 ${v >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatAmount(v)}
</td>
))}
<td className={`px-4 text-right tabular-nums font-bold text-lg w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatAmount(data.net_total)} kr
</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}