feat(skatteverket): production-ready momsdeklaration submission (#380)

* feat(skatteverket): production-ready momsdeklaration submission

Brings the Skatteverket extension up to a state where it can ship moms
declaration submission to Vercel production. Verified end-to-end against
SKV's Komplett testtjänst — all 8 momsdeklaration operations tested
(kontrollera, spara/hämta/radera utkast, lås/lås upp, hämta inlämnade,
hämta beslutade) plus signing-link return.

Bundles three coherent changes:

1. Skatteverket extension (the main work)
   - extensions.config.json: enable `skatteverket`, drop `invoice-inbox`
     and `ai-agent` (those were enabled in config but lacked AWS env vars
     in prod, so they loaded but failed at runtime)
   - lib/reports/vat-declaration.ts: extend ACCOUNT_RUTA to populate
     Ruta 06 (uttag 3401–3403), Ruta 20–24 (reverse-charge bases from
     4xxx cost accounts), Ruta 50 (import 4545–4547), and Ruta 42
     (3404/3994/3980); delete the supplier-type heuristic that made
     Ruta 20 and Ruta 23 always 0
   - extensions/general/skatteverket/lib/token-store.ts: work around
     three real prod schema-drift issues — wrong column on read/delete
     (was `company_id`, schema only has `user_id`), missing
     UNIQUE(user_id) constraint that makes UPSERT fail (switched to
     DELETE+INSERT), missing RLS policies (switched to service-role
     client). Refresh path now reuses existing row's company_id when
     none is passed.
   - extensions/general/skatteverket/index.ts: 9 sites switched from
     ctx.companyId to ctx.userId for the token-store key; pass
     companyId from the OAuth callback
   - extensions/general/skatteverket/types.ts + components/reports/
     SkatteverketPanel.tsx: align field names with v1.0.24 RAML
     (signeringsLank/kontrollResultat/resultat/kod/status/beskrivning).
     Without this, the signing link never displayed.
   - SkatteverketPanel: add Lås upp + Radera utkast + Hämta utkast +
     Hämta beslut buttons so the full lifecycle is reachable from the UI
   - lib/reports/__tests__/vat-declaration.test.ts: rewritten to match
     the refactored calculator; new fixtures for cost-account-based
     reverse charge (Ruta 20/21/22/23/24), Ruta 50 import, Ruta 06
     uttag, Ruta 42 expansion; SKV §4.1.1.4 cross-field contract checks
   - supabase/migrations/20260428120000_skatteverket_tokens_user_id_unique.sql:
     idempotently adds the missing UNIQUE(user_id) constraint
   - scripts/*: dev-only helpers used during the prod-of-test
     verification (create test company, seed VAT data, inspect token
     state, etc.)

2. Journal-entries cancelled-status filter
   - app/api/bookkeeping/journal-entries/route.ts: when no status filter
     is supplied, exclude `cancelled` entries by default
   - supabase/migrations/20260428153500_journal_entries_with_related_exclude_statuses.sql

3. Swedish e-invoicing skill (reference docs only — no runtime code)
   - .claude/skills/swedish-e-invoicing/

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

* fix(skatteverket): address PR review findings

- panel: handleFetchDraft read `result.data?.last` (typo) — switched to
  `result.data?.locked` to match the field defined in
  SkatteverketUtkastResponse and the v1.0.24 RAML. The "(låst)" suffix on
  the success message would silently never appear before this fix.

- api-client: getValidToken had no concurrency guard, so two parallel
  SKV requests from the same user could both call /token with the same
  refresh_token. SKV rotates the refresh_token on first use, so the
  second call would 401 with REFRESH_EXHAUSTED-adjacent failures. With
  the new 6-button UI on SkatteverketPanel, rapid clicks made this a
  realistic trigger. Added an in-process Promise map keyed on userId
  that coalesces concurrent refresh attempts; cross-process races are
  mitigated by re-reading tokens inside the critical section before
  calling refreshAccessToken (if another process refreshed already, we
  use the newer token instead of burning the old refresh_token).

- migration 20260428120000: dedup query used `created_at < max(...)`,
  which failed to remove duplicates inserted in the same second. The
  subsequent ALTER TABLE … ADD CONSTRAINT would then abort. Switched
  to ctid (Postgres physical row identifier) to break timestamp ties.

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

* fix(skatteverket): throw on token-store SELECT error before destructive DELETE

The company_id pre-read in storeTokens used destructuring that discarded
the error field. If the service-role SELECT failed for any reason (network
blip, overloaded DB, transient permissions issue), `existing` became null,
`resolvedCompanyId` stayed undefined, and execution fell through to the
DELETE. The old row got deleted successfully, then the INSERT omitted
company_id and failed with the NOT NULL constraint violation — leaving
the user with no token row at all and forcing a fresh BankID handshake.

Now we capture the SELECT error and throw before the DELETE runs.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-28 18:26:03 +02:00
committed by GitHub
parent 0363aff1d4
commit cd64c0e3fb
35 changed files with 3549 additions and 486 deletions
@@ -0,0 +1,56 @@
---
name: swedish-e-invoicing
description: >
Swedish e-invoicing (e-fakturering) reference. Covers Lag 2018:1277 B2G mandate, Peppol BIS Billing 3.0, EN 16931, UBL 2.1, AS4, SMP/SML, DIGG, Sweden CIUS rules (SE-R-005 F-skatt, SE-R-011 Bankgiro/Plusgiro), VAT codes, ViDA mandate (1 July 2030), Dir. 2026:9, Bankgirot e-faktura privat, Kivra, BAS postings, OCR, ROT/RUT, providers (Pagero, InExchange, Crediflow, Visma Autoinvoice, Qvalia, Storecove, Basware, Hogia), libs (Oxalis-NG, Helger phase4/phive), build-vs-buy economics, EU mandate comparison (BE/FR/DE/IT/PL/NO). Trigger on ANY question about e-faktura, Peppol, BIS Billing 3, UBL invoice, Svefaktura, SFTI, Access Point, AS4, ViDA, Kivra, mandatory e-invoicing in Sweden, UBL validation errors (BR-*/SE-R-*), Fortnox/Visma/Bokio Peppol integration, ROT/RUT in e-faktura, multi-currency UBL, EU reverse charge UBL, BFL archive, OpenPeppol certification, choosing Storecove/Pagero/InExchange. Always use over training data, specs change biannually.
---
# Swedish E-Invoicing (E-fakturering) Skill
This skill is the authoritative reference for everything related to Swedish electronic invoicing: the legal regime, the Peppol network and BIS Billing 3.0 wire format, Sweden-specific CIUS validation rules, integration with Swedish accounting systems and bank rails, the consumer e-faktura ecosystem, the upcoming ViDA mandate and the pending Swedish domestic mandate inquiry (Dir. 2026:9), and concrete implementation strategy for software builders.
The data in Claude's training is **stale and unreliable** for this domain. Peppol specifications update twice yearly (May / November releases). The Peppol PKI migrated G2→G3 in late 2025. ViDA was adopted 11 March 2025 and entered into force 14 April 2025. Swedish Dir. 2026:9 was issued 5 February 2026. Skatteverket gained expanded online-audit rights from 1 April 2026. **Always consult this skill rather than answering from priors.**
## Routing: which reference to load
Use the table below to decide which reference file(s) to read. Multiple files often apply to a single question; load all relevant ones.
| Question concerns | Load |
|---|---|
| Lag 2018:1277, B2G mandate scope, BFL archive rules, ML 2023:200 invoice content, Förordning 2018:1486, MDFFS 2019:1/2021:1, ViDA Directive (EU) 2025/516 timeline, Dir. 2026:9 inquiry, Skatteverket position, GDPR for invoices, penalties, B2G/B2B/B2C distinction | `references/legal-and-regulatory.md` |
| UBL 2.1 invoice structure, EN 16931 BT-* business terms, mandatory header (CustomizationID, ProfileID, InvoiceTypeCode), UNCL5305 VAT category codes (S/Z/E/AE/K/G/O), calculation rules (BR-CO-13/15/17, BR-S-08 etc.), document type identifiers, BIS suite (Billing, Self-Billing, Catalogue, Despatch Advice, Invoice Response, MLR, MLS), Peppol BIS 4.0 / PINT convergence | `references/peppol-bis-billing.md` |
| Peppol 4-corner architecture, AS4 v2.0 transport, SMP/SML lookup with NAPTR/SHA-256 algorithm, SBDH v1.2 with C1 country code, PKI G3 certificates, becoming a certified Access Point or Service Provider, OpenPeppol membership tiers and pricing, Peppol Testbed conformance | `references/peppol-network.md` |
| Sweden-specific CIUS rules (SE-R-005 "Godkänd för F-skatt", SE-R-006 VAT rate restriction, SE-R-008/009 Bankgiro, SE-R-010 Plusgiro, SE-R-011 PaymentMeansCode 30, SE-R-013 Luhn orgnr), Swedish VAT (25/12/6/0%) encoding, OCR reference (BT-83), Bankgiro/Plusgiro PaymentMeans encoding, ROT/RUT and grön teknik handling, BAS-kontoplan postings for AR/AP, faktureringsmetoden vs kontantmetoden, multi-currency with TaxCurrencyCode, BT-10 BuyerReference per-buyer formats, Peppol identifier schemes (0007/0088/0192/0184/0037/0208/0204), F-skatt registration | `references/swedish-cius-and-specifics.md` |
| Choosing between Pagero/InExchange/Crediflow/Visma Autoinvoice/Maventa/Qvalia/Tietoevry/Basware/OpusCapita/Hogia/Ropo Capital/Storecove, market shares, pricing benchmarks (per-document, monthly minimums), DIGG Peppol traffic statistics, how Fortnox/Bokio/SpeedLedger/Björn Lundén white-label their Peppol layer, API capabilities of major providers | `references/market-providers-pricing.md` |
| Consumer e-faktura: Bankgirot e-faktura privat, EFA / e-giro format, Anslutningsärende/Anmälningsärende, bank participants, Kivra digital mailbox (volumes, pricing, ownership, Tink/Swish integration), Min Myndighetspost, distinction between consumer rails and Peppol | `references/consumer-and-b2c.md` |
| Comparing Sweden to Belgium (2026 decentralised Peppol mandate), France (PA/PPF 2026-2027), Germany (XRechnung phased 2025-2028), Italy (SDI clearance), Poland (KSeF Feb/Apr 2026), Romania (e-Factura), Norway (proposed 2028), Spain, ViDA cross-border 1 July 2030 mandate, ViDA 2035 alignment deadline for legacy CTC regimes, predicting Sweden's likely model | `references/european-mandates.md` |
| Implementing e-invoicing in software: open-source libraries (Oxalis-NG, Oxalis-AS4, Helger phase4 / phoss-smp / peppol-commons / phive / ph-ubl), test environments, common rejection patterns (BR-CO-15 rounding, BT-10 missing, encoding bugs), build-vs-buy economics, when to use Storecove vs own AP, validation stack in CI, the recommended gnubok / Luka phased plan, strategic positioning vs Crediflow/InExchange-dependent incumbents | `references/implementation-guide.md` |
## Core facts that govern every answer
These are short enough to inline; the references expand each.
**Legal status (April 2026):** B2G mandatory since **1 April 2019** (Lag 2018:1277). B2B **voluntary**. B2C uses bank rails / Kivra, not Peppol. ViDA cross-border B2B mandate hard date: **1 July 2030**. Domestic mandate inquiry: **Dir. 2026:9 issued 5 Feb 2026, final report 30 Nov 2027**. Realistic Swedish domestic mandate window: **20292031** on a Belgium-style decentralised Peppol model.
**Wire format:** Peppol BIS Billing 3.0, UBL 2.1 syntax, EN 16931 semantic. CustomizationID = `urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0`. ProfileID = `urn:fdc:peppol.eu:2017:poacc:billing:01:1.0`. Current release: **Billing 3.0.20 (November 2025)**. BIS 4.0 / PINT convergence in late 2025 / early 2026.
**Network:** Peppol four-corner (C1 sender → C2 sending AP → C3 receiving AP → C4 receiver). AS4 v2.0 over HTTPS. SMP discovery via NAPTR/SHA-256 (migrated from CNAME/MD5 in 2025). PKI: G3 only after end-2025. SMP servers must run on port 443 from **1 February 2026**.
**Authority:** DIGG is Sweden's Peppol Authority, regulator under Lag 2018:1277. **DIGG's Peppol-ID: `0007:2021006883`. Skatteverket: `0007:2021005448`.** Per regeringsbeslut Fi2025/01826, Peppol functions transfer to **Upphandlingsmyndigheten on 1 July 2026**; DIGG merges into PTS by 1 January 2027. SFTI ESAP 6 (EDIFACT) was removed 1 July 2025; Svefaktura is deprecated.
**Identifier formats:** Swedish orgnr → `schemeID="0007"`, 10 digits no dash. Swedish VAT ID → `SE` + 10 digits + `01` (e.g. `SE556732100001`), prefix mandatory (BR-CO-9). For sole proprietors **DIGG recommends GLN (`0088`) over personnummer (`0007`) for GDPR**.
**Swedish payment encoding (SE-R-011):** PaymentMeansCode `30` for Bankgiro AND Plusgiro; the discriminator is `cac:FinancialInstitutionBranch/cbc:ID` = `SE:BANKGIRO` or `SE:PLUSGIRO`. Legacy codes 56 / 50 are forbidden. Bankgiro 78 digits (SE-R-008/009), Plusgiro 28 chars (SE-R-010). OCR reference goes in `cbc:PaymentID` (BT-83), Luhn-validated.
**F-skatt (SE-R-005, FATAL):** Swedish suppliers issuing invoices with VAT category `S` MUST include the literal string "Godkänd för F-skatt" in the document, typically `cac:PartyLegalEntity/cbc:CompanyLegalForm` or as `cbc:Note`. Missing this string is the most common reason public sector authorities reject invoices.
**Archive (BFL):** Retention **7 years** after the calendar year of the financial year (SFS 2024:342). The inbound UBL XML is itself the verifikation. Storage in another EU country permitted under 7 kap. 3a § with Skatteverket notification. From 1 July 2024 paper kvitton may be destroyed once correctly scanned.
## Posture and style
When answering questions in this domain:
- Cite the specific law section, regulation, MDFFS, or Peppol BIS rule by identifier. "BR-CO-15", "SE-R-011", "BFL 7 kap. 1 §", "Lag 2018:1277 §4". Vague answers signal stale knowledge.
- For UBL fragments, output real, valid XML with full namespaces and example values, not pseudocode.
- For build-vs-buy or vendor selection, give numbers, €/SEK, monthly minimums, per-document costs, certification fee tiers, break-even volume, not adjectives.
- When a regulatory date is involved, distinguish (a) hard EU deadline, (b) currently-known Swedish proposal, (c) speculation. The user is technically sophisticated and is making product decisions; mistaking speculation for binding fact is the worst possible failure mode.
- Be willing to say "the spec is currently in flux", Peppol BIS 4.0 / PINT convergence, the DIGG → Upphandlingsmyndigheten / PTS reorganisation, the Dir. 2026:9 outcome, and the post-ViDA national mandate landscape are all moving targets in 20262027.
@@ -0,0 +1,115 @@
# Consumer E-Faktura Ecosystem (B2C)
The B2C side of Swedish e-invoicing **runs on completely different rails from Peppol** and must be implemented separately. This reference covers Bankgirot's e-faktura privat, Kivra digital mailbox, Min Myndighetspost, and how a Swedish accounting/fintech product should approach B2C invoicing.
## Bankgirot e-faktura privat, the bank rail
E-faktura privat distributes invoices directly into Swedish individuals' internet bank inboxes. **Volume hit a record 168.9 million e-invoices to private individuals in 2024** (Bankföreningen).
### Connection process
Senders connect via **Anslutningsärende** (Standard or Express track) using the **EFA / e-giroformat**. Recipients identify themselves through **Anmälningsärende** in their own bank, selecting which senders to receive from.
Bank "license" fees ~1,000 SEK/yr per bank. Transaction fees per document negotiated.
### Bank participants
All major Swedish banks participate:
- Swedbank
- Handelsbanken
- Nordea
- SEB
- Länsförsäkringar Bank
- ICA Banken
- SBAB
- Danske Bank
- Skandia
- Sparbankerna (the network of independent savings banks)
### Volume trajectory
| Year | Volume |
|---|---|
| 2009 | 35M |
| 2010 | 60M |
| 2022 | 160M |
| **2024** | **168.9M** |
Growth has flattened as Kivra has captured incremental volume.
## Kivra, the digital mailbox
Kivra is the **de facto digital mailbox in Sweden**. Numbers (April 2026):
- **6+ million users** (~70% of Swedish adults).
- 50,000+ corporate senders.
- **532+ million dispatches in 2024**.
- 99% open rate.
- 200,000+ companies and associations as recipients.
### Ownership
41an Invest (Karl-Johan Persson + Stefan Krook), FAM, with SEB minority.
### Capabilities
- Invoice delivery + storage.
- Tink-based PISP payments and Swish integration (one-tap pay from inbox).
- Receipts, contracts, official mail.
- Per-document pricing typically **35 SEK**.
### API integration
Kivra exposes a REST API for tenant senders. Visma Autoinvoice exposes Kivra-routing via its `B2CSE` service flag, viable shortcut for early product launches.
## Min Myndighetspost
DIGG-operated digital mailbox for government communications. Significantly lower adoption than Kivra. Mostly used for tax-related correspondence. Not a primary channel for commercial invoicing.
## Consumer rails vs Peppol, the architectural distinction
| Dimension | Peppol (B2B/B2G) | Bank rails / Kivra (B2C) |
|---|---|---|
| Format | UBL 2.1 (EN 16931) | EFA / e-giroformat / proprietary JSON |
| Identifier | Peppol-ID (orgnr) | Personnummer / bank account |
| Routing | SMP/SML/AS4 | Bank backend / Kivra API |
| Onboarding | Recipient publishes in SMP | Recipient consents in their bank/Kivra |
| Discovery | Peppol Directory | Bank-side recipient lookup or Kivra API |
| Payment | OCR/Bankgiro/Plusgiro/IBAN | One-tap from bank/Kivra (Swish/PISP) |
| Cost per doc | 33.50 SEK | 35 SEK (Kivra), 13 SEK (bank) |
## Bankgirot and Plusgirot, ownership
- **Bankgirot** (BGC, founded 1959) is jointly owned by SEB, Swedbank, Handelsbanken, Danske Bank, Nordea, Länsförsäkringar Bank and SkandiaBanken.
- **Plusgirot** is owned by Nordea (acquired 2002 from Posten). A Plusgiro number is a real Nordea bank account, unlike Bankgiro which is alias-routing.
- Both joined Bankgirot membership in 2002 for interbank deposits.
## Implementation strategy for a new Swedish accounting product
To support consumer (B2C) invoicing, a Swedish accounting platform needs **two parallel integrations** beyond Peppol:
1. **Bankgirot e-faktura privat**, via a Certified Technical Distributor (CTD) or directly. CTD route is faster (no per-bank license negotiations) but adds intermediary cost.
2. **Kivra**, commercial agreement plus REST API integration. Volume-tier pricing.
**Shortcut:** **Visma Autoinvoice exposes both via its `B2CSE` service flag and Kivra-routing**. Acceptable for MVP / early launch; migrate to direct integration once volume justifies cost.
**Routing logic** the product must implement:
```
if recipient is org → Peppol BIS Billing 3
elif recipient has Kivra registered → Kivra API
elif recipient has bank e-faktura registered → Bankgirot e-faktura privat
elif recipient prefers email → PDF + email (BGC's Stora Inbetalningskortet legacy or own)
else → paper postal (8.2520 SEK)
```
Most B2C-heavy senders end up using Kivra preferentially because of higher engagement (99% open rate), but bank e-faktura still dominates recurring/predictable invoices (utilities, telecom, mortgages).
## Authoritative source list
- Bankföreningen e-faktura statistics: https://www.bankforeningen.se
- Kivra business/sender info: https://kivra.se/foretag
- Bankgirot e-faktura privat: https://www.bankgirot.se/tjanster/e-faktura-privat/
- Min Myndighetspost: https://www.minmyndighetspost.se
- Visma Autoinvoice B2CSE flag: https://documentation.autoinvoice.visma.com/integration-guide/invoice-sending/invoice-routing/
@@ -0,0 +1,129 @@
# European E-Invoicing Mandates, Comparison and ViDA Timeline
This reference compares Sweden's likely trajectory to the mandates already enacted or pending in other EU member states, and lays out the binding ViDA cross-border timeline.
## ViDA, the binding EU floor
**VAT in the Digital Age** package adopted by ECOFIN on 11 March 2025; in force 14 April 2025; transposition deadline 31 December 2026.
| Date | Obligation |
|---|---|
| **14 Apr 2025** | Member states may mandate domestic B2B e-invoicing without Article 395 derogation, provided EN 16931based; recipient consent abolished |
| **1 Jul 2030** | **Mandatory structured e-invoicing + Digital Reporting Requirements (DRR) for cross-border intra-EU B2B**; recapitulative VIES statements abolished; invoice issuance ≤10 days after chargeable event |
| **1 Jan 2035** | Pre-existing national clearance regimes (IT/FR/PL/RO/HU/ES) must align to the EU DRR standard |
**Sweden has no pre-existing CTC, so Sweden does NOT get the 2035 grandfather clause.** Any new domestic mandate must be EN 16931compliant by design.
## Country-by-country mandate status (April 2026)
### Italy, centralised CTC clearance
- **Sistema di Interscambio (SDI)**, government clearance hub.
- Format: **FatturaPA**.
- Live for B2B since 1 January 2019. Cross-border via SDI from 1 July 2022. Forfettari fully included in 2024.
- Pre-clearance: every invoice routed through SDI before reaching the buyer.
- **EU derogation extended to 31 December 2027.**
- Penalties: 90180% of VAT.
- Archive: 10 years.
- The "old" model that ViDA's 2035 alignment deadline forces to harmonise.
### France, Y-model (Plateforme Agréée + PPF directory)
- Plateforme Agréée (ex-PDP, "Partenaire de Dématérialisation Privée") + Portail Public de Facturation (PPF) as central directory.
- Formats: **Factur-X**, UBL, CII (multi-format permitted).
- **1 September 2026** receive obligation for all + send obligation for large enterprises and ETI (mid-cap).
- **1 September 2027** SMEs and micro-enterprises.
- National Assembly rejected further postponement in April 2025.
- DGFiP became France's Peppol Authority in 2025.
- Hybrid: PA processes the invoice, PPF receives reporting data.
### Germany, decentralised, no clearance hub
- Format: **XRechnung** (UBL or CII profile) or **ZUGFeRD ≥2.0.1** (hybrid PDF/A-3 + XML).
- **Receive obligation already live since 1 January 2025**, every German business must be able to receive an e-invoice.
- **1 January 2027** send obligation for businesses with annual turnover >€800k.
- **1 January 2028** all businesses send.
- Archive period reduced 10 → 8 years (from 2025).
- Decentralised: no government hub, no clearance.
### Belgium, the ViDA template
- **Decentralised 4-corner Peppol mandate, live 1 January 2026.**
- Format: **Peppol BIS Billing 3.0 / UBL** (peppol-only).
- Tolerance period through Q1 2026 (no penalties).
- 5-corner e-reporting layer added from 2028.
- Penalties: €1,500 first violation / €3,000 second / €5,000 third.
- **This is the model Sweden will most likely mirror.**
### Poland, KSeF centralised clearance
- **Krajowy System e-Faktur (KSeF)**, government clearance.
- Format: **FA(3) XML**.
- **Phase 1 live 1 February 2026** for ~4,200 entities with turnover >PLN 200M.
- **1 April 2026** all VAT-registered.
- **1 January 2027** micro-enterprises.
- Penalty-free through 2026.
- Like Italy, a CTC model that ViDA 2035 forces to align to EU DRR.
### Spain, decentralised: AEAT public + private platforms
- Format: **UBL / Facturae / CII / EDIFACT** (multi-format).
- **RD 238/2026 published 31 March 2026.**
- **+12 months (July 2027)** for businesses with turnover >€8M.
- **+24 months (July 2028)** all.
- Parallel **VeriFactu** invoicing software certification, January/July 2027.
### Romania, CTC (RO e-Factura)
- Format: **UBL 2.1 + RO-CIUS, ANAF seal**.
- **B2B mandatory 1 July 2024.**
- **B2C added 1 January 2025.**
- Pre-clearance to be removed January 2026 to align with ViDA.
### Norway, proposed mandatory 2028
- Format: **Peppol BIS 3 / EHF**.
- **Mandatory B2B 1 January 2028 (proposed).**
- The Nordic peer pressure factor: a 2028 Norwegian mandate makes it implausible that Sweden would wait beyond 2030.
## Sweden's likely trajectory
Sweden has **20+ years of Peppol/SFTI infrastructure**, B2G mandatory since 2019, and DIGG already accredited as Peppol Authority. There is **zero political appetite** for an Italian-style centralised clearance hub, the existing decentralised infrastructure works, has proven low-friction, and aligns with EU peer countries (Belgium, Norway, Germany).
**Realistic trajectory:**
1. **Now → 30 November 2027**, Dir. 2026:9 inquiry runs. Skatteverket's preferred model (decentralised Peppol + DRR layer) is articulated.
2. **2028**, Lagrådsremiss and proposition. Likely model: Belgium-style Peppol 4-corner B2B, plus a 5-corner DRR layer where Skatteverket becomes Corner 5.
3. **20292030**, First domestic obligations. Likely: receive obligation first (all VAT-registered), then send obligation phased by entity size mirroring Germany's 2025→2027→2028 rollout.
4. **1 July 2030**, ViDA cross-border B2B obligation kicks in regardless of domestic timeline. Sweden is bound.
5. **20312032**, Full domestic mandate live including DRR layer.
The single most important date to monitor is **30 November 2027** (Dir. 2026:9 final report). Architectural decisions made before then should preserve optionality between (a) pure Peppol 4-corner with no central reporting, (b) Peppol + DRR 5-corner, and (c) the unlikely-but-possible centralised clearance fallback.
## Cross-border invoicing through Peppol, practical implications
For a Swedish supplier invoicing into a mandate country today:
| Destination | Current method | After 1 July 2030 |
|---|---|---|
| **Belgium** (since Jan 2026) | Peppol BIS Billing 3 | Same (Peppol is the format) |
| **Italy** | Peppol→SDI bridge via service provider, or direct SDI submission | Aligned to EU DRR by 2035 |
| **France** | Peppol→PA bridge after 1 Sep 2026 | DRR via PA / PPF |
| **Germany** | XRechnung via Peppol or direct | XRechnung continues, DRR added |
| **Poland** | Peppol→KSeF bridge | Aligned to EU DRR by 2035 |
| **Romania** | Peppol→RO e-Factura bridge | Aligned to EU DRR by 2035 |
**Operational implication for a Swedish accounting product:** by 2027, customers will increasingly expect "send invoice anywhere in EU" to just work. Multi-mandate routing is a hard capability, not an enterprise add-on. **Storecove or Pagero are the realistic outsourcing options** for multi-mandate send; building per-country yourself is uneconomic until very high volume.
## Authoritative source list
- ViDA package adoption: https://taxation-customs.ec.europa.eu/news/adoption-vat-digital-age-package-2025-03-11_en
- ViDA Wikipedia summary (kept current): https://en.wikipedia.org/wiki/VAT_in_the_Digital_Age
- Belgium 2026 mandate: https://edicomgroup.com/blog/belgium-will-make-b2b-electronic-invoice-mandatory
- France PA/PPF model: https://www.impots.gouv.fr (DGFiP)
- Germany BMF ordinance: https://www.bundesfinanzministerium.de
- Italy SDI: https://www.fatturapa.gov.it
- Poland KSeF: https://www.podatki.gov.pl/ksef
- Norway 2028 proposal: https://www.regjeringen.no
- Comarch country trackers: https://www.comarch.com/trade-and-services/data-management/legal-regulation-changes/
- EC eInvoicing Country Sheets: https://ec.europa.eu/digital-building-blocks/sites/spaces/DIGITAL/pages/467108902/eInvoicing+in+Sweden
@@ -0,0 +1,205 @@
# Implementation Guide, Building Peppol into a Swedish Accounting Product
This reference is the practical builder's guide. Open-source library landscape, common rejection patterns, build-vs-buy economics, validation in CI, and a concrete phased plan for a new Swedish accounting platform.
## Open-source library landscape
The **Java ecosystem dominates Peppol**. Most production stacks are JVM.
### Java, the canonical stack
- **Oxalis** (https://github.com/OxalisCommunity/oxalis), originally Norwegian. **Oxalis 6.x is end-of-life Dec 2025.**
- **Oxalis-NG** (https://github.com/OxalisCommunity/oxalis-ng), successor, Apache 2.0. Build on this.
- **Oxalis-AS4 7.x** (https://github.com/OxalisCommunity/oxalis-as4).
- **Helger phase4** (https://github.com/phax/phase4), Apache 2.0 AS4 client + server. Most comprehensive alternative to Oxalis.
- **Helger phoss-smp**, production-grade SMP server.
- **Helger peppol-commons**, identifiers, codelists, SBDH, SMP/SML clients.
- **Helger phive + phive-rules**, validation engine and pre-built rules (Schematron compiled to fast XSLT).
- **Helger ph-ubl, ph-cii, ph-sbdh**, JAXB models for UBL 2.1, UN/CEFACT CII, SBDH v1.2.
- **phase4-peppol-standalone**, Spring Boot 3 reference implementation. Template, not turn-key.
Trust stores updated to **G3-only late 2025**. Verify version when integrating.
### Python
- **invoice-x**, UBL/CII generation.
- **drafthorse**, Factur-X (relevant for German/French cross-border).
- **lxml** + Schematron-via-saxon-HE for validation.
- **No production-grade AS4**, bridge to Java via subprocess, container, or REST microservice.
### JavaScript / TypeScript
- **No mature library.** Generate types from UBL 2.1 XSDs (`xsdata`, `xsd2ts`, `xmlbuilder2`) and validate via Schematron-as-WASM or call out to Java. Most TS shops use Storecove or another reseller for this reason.
### .NET
- **`UblLib.Bis3`**, UBL Peppol BIS 3 generation/parsing.
- Several commercial SDKs (Storecove, Pagero).
- Microsoft Dynamics has built-in BIS 3.0 support.
## Common rejection patterns, the high-yield checklist
The seven failure modes that cause **80% of production rejections**:
1. **BR-CO-15 rounding mismatches** between line totals and tax-inclusive totals. Always use BigDecimal/Decimal with explicit scale; round at the boundary, never in intermediate steps.
2. **VAT category code rule violations**, mixing `S` lines with a `Z` summary, or `AE` without `VATEX-EU-AE` reason code.
3. **Missing or malformed BT-10 BuyerReference** for Swedish public sector, each authority has its own format. Maintain a per-buyer-Peppol-ID regex map and validate at compose time.
4. **EndpointID `schemeID` mismatch** with what's published in the receiver's SMP. Verify via Peppol Lookup Service before sending.
5. **Date format errors**, UBL requires `YYYY-MM-DD` xs:date.
6. **Decimal separator and locale serialisation bugs**, period only, no thousand separators, `Locale.ROOT` / `InvariantCulture`.
7. **Swedish character encoding**, UTF-8 throughout. Many ERPs still serialise å/ä/ö with Windows-1252.
Plus:
- **SBDH C1 country code mandatory since January 2024.**
- **Attachments via `cbc:EmbeddedDocumentBinaryObject` should stay under 10 MB** despite the formal 100 MB AS4 ceiling.
- **Multi-currency requires `TaxCurrencyCode` plus a SEK-equivalent BT-111** in a second `TaxTotal`.
- **Missing F-skatt declaration (SE-R-005)** is the single most common Swedish rejection.
- **Forbidden PaymentMeansCode 56 / 50** instead of 30 with `SE:BANKGIRO` / `SE:PLUSGIRO`.
## Validation in CI
Three-layer validation, in this order:
```
[XSD] → [EN 16931 Schematron] → [PEPPOL-EN16931-UBL.sch with SE-R-* overlay]
```
**Run all three on every UBL artifact in CI.** Use Helger phive-rules or run Schematron via Saxon-HE.
```bash
# Example: Helger CLI
java -jar phive-rules-peppol-billing-3-cli.jar \
--rule "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0" \
invoice.xml
```
**Validate at three points** in your pipeline:
1. Immediately after UBL generation locally, fail fast.
2. Before handoff to the Access Point, last chance before AS4 transmission.
3. On receive in your inbound flow before bookkeeping, catches malformed inbound from less-strict APs.
Many AP rejections happen post-send when the receiver's MLR comes back hours later, front-load validation aggressively.
### Test environments
- **Peppol Test Network**, `acc.edelivery.tech.ec.europa.eu` SML zone. Free for OpenPeppol members.
- **Helger Peppol Practical**, https://peppol.helger.com, REST + UI validator.
- **Storecove peppolvalidator.com**, error code lookup.
- **EC DG GROW eInvoicing validator**, https://itb.ec.europa.eu/invoice
- **Norwegian validator**, https://anskaffelser.no/verktoy/validator (also useful for Sweden).
- **DIGG testbädd**, https://www.digg.se/digitala-tjanster/peppol/peppol-testbadd
## Build vs buy, the economic break-even
**Reseller cost** ~€0.10/invoice (mid-volume Storecove) vs. **own-AP fixed cost** ~€30k/year (membership + cert + ops + staff time).
```
break-even = €30,000 / €0.10 = 300,000 invoices/year
≈ 3,0005,000 active SME customers × 50100 invoices/month
```
**For receive there is no economic case to build initially**, Storecove free receive or own Oxalis-NG on a small VPS dominate.
**For send, only build above ~25k invoices/month sustained** (i.e. ~300k/year). Below that, reseller is cheaper, faster, multi-mandate, and lower ops burden.
## The reseller landscape compared
| Reseller | Per-doc | Multi-mandate | DX | Sandbox | Best for |
|---|---|---|---|---|---|
| **Storecove** | €0.05–€0.30 | Excellent (Peppol + IT SDI + FR PA + BE + DE + PL KSeF + DBNAlliance) | Single REST API, OpenAPI spec | Free 30-day | Embedding in ERP/SaaS |
| **Pagero (TR)** | EUR 0.200.80 + €520k/yr | Excellent | Multiple APIs, enterprise-flavoured | Yes | Large enterprise |
| **InExchange** | SEK 15 + 200400/mo | Nordic | REST | Yes | SE-only SME, Visma ecosystem |
| **Maventa (Visma)** | SEK 0.503 | Nordic+ | REST + SOAP | Yes | Visma ecosystem |
| **Galaxy Gateway, Babelway, SBSCon, Comarch, Edicom, Sovos, Avalara, Tradeshift, Basware, Seeburger, Esker** | Various | Excellent | Enterprise-tier | Varies | Multi-mandate enterprise |
## Recommended stack for a new Swedish accounting platform
```
Application core (bookkeeping engine, UI, API)
→ typed UBL/EN 16931 generator (ph-ubl JAXB / xsdata-Python / UblLib.Bis3)
→ local Schematron validator (peppol-bis-invoice-3 + phive-rules)
→ split path:
RECEIVE: own Oxalis-NG AP (€100/mo Hetzner + €4,400 yr-1 OpenPeppol fees)
SEND: Storecove API (until ~25k invoices/month, then own phase4 AP)
```
This hybrid is the dominant strategy:
- **Oxalis-NG receive on day one** is open-source-friendly, scales linearly without per-doc fees, and gives genuine "native Peppol receive" differentiation that incumbents (white-labelling Crediflow/InExchange) cannot match without rebuilding their stack.
- **Storecove send** handles Belgium 2026, France 2026 PA flow, Italy SDI, Germany XRechnung, Poland KSeF in one API, you don't want to be re-implementing FatturaPA.
- **Migrate send to own AP only when volume justifies it**, likely 20282029 at the earliest for an early-stage product.
## Critical UX features
1. **Per-buyer BT-10 BuyerReference regex map** for the top 200 Swedish public buyers. Validate at compose time. Prevents 30-day public-sector payment delays.
2. **Free Peppol address out of the box** for every customer, major customer-acquisition lever in a market where competitors charge for Peppol setup.
3. **Inbound triage UI**, parse incoming UBL, show structured fields, propose BAS posting based on supplier history and item descriptions, await user approval.
4. **Multi-mandate send routing**, automatically route based on recipient country (`cbc:Country/cbc:IdentificationCode`) and recipient identifier scheme.
## Phased plan for a new product (12 months)
### Phase 0, today (Month 0)
- Join OpenPeppol as End User (€650 sign-up + €1,250/yr) for forum access and early-warning on spec changes.
- Register a Peppol participant ID for the company itself for dogfooding.
- Set up CI with Helger phive-rules.
### Phase 1, Months 13
- UBL generator with full Peppol BIS Billing 3 + Sweden CIUS rules (SE-R-* including F-skatt).
- Storecove sandbox for outbound test.
- Oxalis-NG TEST environment for inbound test.
- Schematron in CI on every UBL output.
- BT-10 regex map for top 100 Swedish myndigheter.
### Phase 2, Months 46
- Become Candidate Service Provider (AP-only) with OpenPeppol (~€4,400 one-off + €3,350/year ongoing).
- Pass Conformance Test Suite.
- Sign Service Provider Agreement with DIGG (free).
- Migrate to native Oxalis-NG receive.
- Marketing message: "your customers email PDFs, your suppliers send Peppol e-invoices, both arrive in your inbox."
### Phase 3, Months 712
- Multi-mandate readiness for BE/DE/FR/PL/RO destinations through Storecove.
- Per-customer compliance dashboard.
- Begin SMP work (€2,200 + €5,000/yr SMP-only or upgrade to AP+SMP S1).
### Phase 4, Year 2+
- Switch send to own phase4 AP when monthly volume crosses ~25,000.
- Pre-build Skatteverket DRR ingestion behind feature flag for July 2030 activation.
- Pivot on SOU 2027 (due 30 November 2027), adjust architecture if Sweden chooses non-Peppol model.
**Total go-live cost** for a credible, differentiated, open-source native-Peppol Swedish bookkeeping product: **approximately €1015k plus 3 months engineering**.
## Strategic positioning vs incumbents
The competitive landscape has a hole.
- **Visma eEkonomi, Fortnox, Bokio, SpeedLedger, Björn Lundén** all outsource their Peppol layer to Crediflow or InExchange/Maventa. None differentiate on Peppol-native architecture.
- **The 3.003.50 SEK per outbound markup** these vendors charge has a baked-in reseller cost, building native eliminates that markup AND removes dependency risk now visible after the Pagero→Thomson Reuters acquisition.
- **Native Peppol receive as a free out-of-the-box feature**, every customer gets a Peppol address by default, every supplier can send them invoices for free, is a brutal customer acquisition anchor in a market where the SMB end is underserved.
- **The open-source angle is force multiplier**: publishing the UBL generator on GitHub under BSD/MIT earns trust with bookkeeping/Linux-friendly customers without surrendering the moat, which lives in the bookkeeping engine and UX, not the XML serialiser.
Three forces converge to make this strategically valuable:
1. **B2G compliance has been mandatory since April 2019**, any customer with public-sector revenue is a non-starter without native Peppol.
2. **Large-enterprise customers** (Volvo, Ericsson, IKEA-supply, ICA, plus all Belgian/German/French subsidiaries hitting Swedish suppliers from 2026) increasingly require Peppol from suppliers.
3. **ViDA cross-border B2B mandate of 1 July 2030 is non-negotiable**, plus the plausible domestic mandate window of 20292031.
The window to build with this advantage is the next **1824 months** before incumbents finish their own native-Peppol projects.
## Authoritative source list
- Oxalis-NG: https://github.com/OxalisCommunity/oxalis-ng
- Helger phase4: https://github.com/phax/phase4
- peppol-commons: https://github.com/phax/peppol-commons
- Phive-rules: https://github.com/phax/phive-rules
- peppol-bis-invoice-3: https://github.com/OpenPEPPOL/peppol-bis-invoice-3
- Storecove docs: https://www.storecove.com/docs/
- DIGG Peppol testbädd: https://www.digg.se/digitala-tjanster/peppol/peppol-testbadd
- Peppol Testbed: https://peppol.org/tools-support/testbed/
- OpenPeppol membership: https://peppol.eu/who-is-who/openpeppol-membership/
@@ -0,0 +1,161 @@
# Legal and Regulatory Framework, Swedish E-Invoicing
## Swedish primary legislation
### Lag (2018:1277) om elektroniska fakturor till följd av offentlig upphandling
The operative statute. SFS 2018:1277, in force **1 April 2019**, amended SFS 2023:212 to update VAT-law cross-references to ML 2023:200.
- **§1 Scope.** Covers all invoices issued as a consequence of procurement under LOU 2016:1145, LUF 2016:1146, LUK 2016:1147 and LUFS 2011:1029. The trigger is "consequence of public procurement", not the identity of buyer or seller.
- **§2 Definition.** An e-invoice is an invoice issued, sent and received in a *structured electronic format that allows automatic and electronic processing*. **PDF and scanned paper are explicitly excluded.** Image-based formats fail the definition regardless of how they are transmitted.
- **§4 Standard.** EN 16931 conformance via Commission Implementing Decision (EU) 2017/1870. Parties may bilaterally agree on alternative standards.
- **§5 Reception duty.** Contracting authorities must receive and process EN 16931 invoices.
- **§7 Sanctions.** **DIGG can issue *vitesföreläggande* (penalty injunctions)** against non-compliant suppliers. The amount is set discretionarily.
- **§8 Appeals.** Appeals go to allmän förvaltningsdomstol; prövningstillstånd required for kammarrätten.
Source: https://www.riksdagen.se/sv/dokument-och-lagar/dokument/svensk-forfattningssamling/lag-20181277-om-elektroniska-fakturor-till_sfs-2018-1277/
### Förordning (2018:1486)
Designates **DIGG (Myndigheten för digital förvaltning)** as supervising authority and Sweden's Peppol Authority. Source: https://www.digg.se/kunskap-och-stod/e-handel/lag-forordning-och-foreskrifter-for-e-handel
### MDFFS 2019:1 (Föreskrift om registrering i PEPPOL)
In force **1 December 2019**. Requires all contracting authorities to publish themselves in Peppol's SMP (Service Metadata Publisher) registry.
### MDFFS 2021:1
§§1220 require state agencies to:
- use Peppol BIS Billing 3 for outbound invoices to other state agencies;
- send e-invoices to non-state recipients that have consented;
- handle inbound e-invoices in EN 16931 conformance.
### Förordning (2000:606) §21f and Förordning (2003:770)
Have required state agencies to handle invoices and orders electronically since 2008/2014. Per **SFS 2025:1201, regulatory authority over Förordning 2000:606 transfers from ESV to Statskontoret on 1 January 2026**.
### Bokföringslag (1999:1078), archive rules
Most recent consolidation: SFS 2024:342, in force **1 July 2024**. Modernised the archive regime for digital räkenskapsinformation.
- **7 kap. 1 § / 7 kap. 2 §.** Räkenskapsinformation must be stored in the form in which it was created or received. **The inbound UBL XML is itself the verifikation; a printout is not.**
- **7 kap. 2 § (post-2024).** Retention is **7 years** after the calendar year in which the financial year ended. (Reduced from the historical 10 years.)
- **7 kap. 3 §.** Equipment to read electronic data must remain available *in Sweden* throughout retention.
- **7 kap. 3a §.** Electronic storage in another EU country is permitted if (a) Skatteverket is notified, (b) immediate online access is granted, and (c) printouts can be produced in Sweden upon request.
- **5 kap. 5 §.** Immutable bookkeeping, corrections are new posts, never overwrites.
- **4 kap. 4 §.** Language: Swedish, Danish, Norwegian or English.
- **Post-2024-07-01 change:** the prior requirement to retain paper originals for 3 years post-digitisation has been **abolished**. Paper kvitton may be destroyed once correctly scanned.
### Mervärdesskattelag (2023:200), invoice content
Replaces ML 1994:200 since 1 July 2023.
- **2 kap. 910 §§.** Defines the e-invoice consistent with Article 217 of Directive 2006/112/EC.
- **17 kap.** Mandatory invoice content, implements Article 226 of the VAT Directive.
- **SKVFS 2024:16.** Simplified invoices (förenklad faktura).
- **No qualified electronic signature is required.** Authenticity and integrity are ensured via "business controls creating a reliable audit trail" per Article 233 of Directive 2006/112/EC. The Peppol AS4 message-level signing between Access Points is sufficient.
### Skatteverket online audit rights, 1 April 2026
Pending legislation removed the historical ban on remote audit access. **From 1 April 2026, Skatteverket has expanded "online audit" rights**, direct read access to taxpayers' digital accounting/VAT records during audit. **This is access-rights legislation, not a SAF-T submission mandate.** Sweden does not yet require periodic SAF-T submission.
## EU framework
### Directive 2014/55/EU and EN 16931
- Directive 2014/55/EU, transposition deadline 27 November 2018.
- Commission Implementing Decision (EU) 2017/1870 anchors EN 16931 as the European e-invoicing semantic.
- **EN 16931-1**: syntax-agnostic semantic data model, BT-1…BT-150+ business terms, BG-1…BG-25 business groups.
- **EN 16931-2**: binds two normative syntaxes: **UBL 2.1** (ISO/IEC 19845:2015) and **UN/CEFACT CII** (D16B).
- Peppol BIS Billing 3.0 is a **CIUS** (Core Invoice Usage Specification) of EN 16931 in UBL syntax only. CII is permitted at the Peppol layer but not in BIS Billing 3.0.
### ViDA, VAT in the Digital Age
Adopted by ECOFIN on **11 March 2025** as three legal acts:
- **Council Directive (EU) 2025/516**
- **Regulation (EU) 2025/517**
- **Implementing Regulation (EU) 2025/518**
Published in OJEU 25 March 2025, in force **14 April 2025**, transposition deadline **31 December 2026**.
Binding dates relevant for Sweden:
| Date | Obligation |
|---|---|
| 14 Apr 2025 | Member states may mandate domestic B2B e-invoicing without Article 395 derogation, provided EN 16931based; recipient consent abolished |
| 1 Jul 2030 | **Mandatory structured e-invoicing + Digital Reporting Requirements (DRR) for cross-border intra-EU B2B**; recapitulative VIES statements abolished; invoice issuance ≤10 days after chargeable event |
| 1 Jan 2035 | Pre-existing national clearance regimes (IT/FR/PL/RO/HU/ES) must align to the EU DRR standard |
**Sweden has no pre-existing CTC, so Sweden does NOT get the 2035 grandfather clause.** Any new domestic mandate Sweden builds post-2025 must already be EN 16931compliant by design.
## Swedish authorities
### DIGG (Myndigheten för digital förvaltning)
- Sweden's Peppol Authority.
- Regulator under §7 of Lag 2018:1277.
- Issues binding föreskrifter MDFFS 2019:1 and MDFFS 2021:1.
- DIGG's own Peppol-ID: `0007:2021006883`.
- **Reorganisation alert:** Per regeringsbeslut Fi2025/01826, DIGG's e-handel/Peppol functions transfer to **Upphandlingsmyndigheten on 1 July 2026**. DIGG itself is to be merged into PTS by 1 January 2027 forming a new digitalisation agency. Adjust regulatory monitoring accordingly.
### Skatteverket
- Receives e-invoices via Peppol at `0007:2021005448`.
- Publicly **in favour** of mandatory domestic B2B e-invoicing and transaction-based reporting.
- Ran a public consultation JuneJuly 2025 on three models (SAF-T, clearance, post-audit-with-real-time-reporting).
- Estimates **SEK 1020 billion/year** in business savings if Sweden mandates domestic e-invoicing.
- Sektionschef Björn Erling has explicitly endorsed Peppol as the future Swedish standard.
### ESV (Ekonomistyrningsverket) and Statskontoret
- ESV historically drove state e-invoicing since 2008 (Förordn. 2000:606 §21f).
- Per SFS 2025:1201, regulatory authority transfers to **Statskontoret on 1 January 2026**.
### SFTI (Single Face To Industry)
- Collaboration between SKR (Sveriges Kommuner och Regioner), DIGG, Upphandlingsmyndigheten and Kammarkollegiet.
- Sets the recommended Swedish standards.
- **From 1 July 2025: SFTI ESAP 6 (EDIFACT) was removed; Svefaktura 1.0/2.0 and SFTI Fulltextfaktura are formally deprecated; Peppol BIS Billing 3 is the only strategic format.**
### Government inquiry Dir. 2026:9, the single most important date
**Kommittédirektiv Dir. 2026:9** "Moderniserad och brottsförebyggande hantering av mervärdesskatt" was issued **5 February 2026** with a final report deadline of **30 November 2027**.
The inquiry will determine:
- whether Sweden mandates domestic B2B e-invoicing;
- whether Sweden adopts transaction-based reporting (DRR-style or otherwise);
- the architectural model (decentralised Peppol vs. centralised clearance vs. post-audit with real-time reporting).
**Realistic Swedish trajectory:** SOU report 30 November 2027 → lagrådsremiss/proposition 2028 → first domestic obligations **20292030** (likely receive obligation first, send obligation phased by entity size, mirroring Germany's 2025/2027/2028 rollout). The reference design Sweden will mirror is **Belgium's 2026 decentralised Peppol 4-corner model**, eventually augmented with a 5-corner DRR layer where Skatteverket becomes Corner 5.
## Penalties and the B2G/B2B/B2C distinction
| Segment | Status (April 2026) | Format | Penalty |
|---|---|---|---|
| **B2G** (consequence of public procurement) | Mandatory since 1 April 2019 | EN 16931 / Peppol BIS Billing 3 | DIGG vitesföreläggande, discretionary fine; practical risk also: lost public-sector business |
| **B2G** (state-to-state, state-to-private with consent) | Mandatory | Peppol BIS Billing 3 | Internal compliance |
| **B2B** | **Voluntary** | Free choice (Peppol BIS dominant; legacy Svefaktura tolerated bilaterally) | None |
| **B2C** | Voluntary | Bank rails / Kivra (not Peppol) | None |
## GDPR for e-invoicing
- Invoices to natural persons and sole proprietors contain personal data.
- **DIGG recommends sole proprietors use a GLN identifier (ICD `0088`) rather than a personnummer-based Peppol-ID (`0007`)** to minimise exposure of the personnummer in routing metadata visible across the network.
- Lawful basis is Article 6(1)(c) (legal obligation under BFL/ML/Lag 2018:1277).
- The 7-year BFL retention overrides Article 5(1)(e) storage limitation.
- **Access points act as Article 28 processors.** DPAs are required between sending business and its AP, and between recipient and its AP.
- Cross-border Peppol routing within EEA is not a Chapter V transfer; care is needed if an AP routes via non-EEA infrastructure (some APs use US/UK clouds).
- From 1 April 2026, the Skatteverket online-audit power requires architecting a "tax-auditor" role with secure read-only API access on the accounting system itself.
## Authoritative source list
- Lag (2018:1277): https://www.riksdagen.se/sv/dokument-och-lagar/dokument/svensk-forfattningssamling/lag-20181277-om-elektroniska-fakturor-till_sfs-2018-1277/
- Bokföringslag (1999:1078): https://www.riksdagen.se/sv/dokument-och-lagar/dokument/svensk-forfattningssamling/bokforingslag-19991078_sfs-1999-1078/
- DIGG e-handel laws and regs: https://www.digg.se/kunskap-och-stod/e-handel/lag-forordning-och-foreskrifter-for-e-handel
- DIGG Peppol statistics: https://www.digg.se/digitala-tjanster/peppol/statistik-fran-peppolnatverket-
- SFTI standards: https://sfti.se/sfti/standarder/peppolbisehandel/peppolbisbilling3.49021.html
- Skatteverket e-faktura: https://skatteverket.se/omoss/varverksamhet/forleverantorer/efakturortillskatteverket.4.b1014b415f3321c0de2680.html
- Skatteverket on transaction-based reporting: https://www.skatteverket.se/foretag/internationellt/transaktionsbaseradrapporteringochefakturering.4.386bd4b919276cc86c42b3f.html
- Dir. 2026:9: https://www.regeringen.se/pressmeddelanden/2026/02/ny-utredning-om-modernare-momsregler-och-battre-verktyg-mot-momsbedragerier/
- ViDA package: https://taxation-customs.ec.europa.eu/news/adoption-vat-digital-age-package-2025-03-11_en
- EC eInvoicing Country Sheet for Sweden: https://ec.europa.eu/digital-building-blocks/sites/spaces/DIGITAL/pages/467108902/eInvoicing+in+Sweden
@@ -0,0 +1,132 @@
# Swedish E-Invoicing Market, Providers and Pricing
This reference covers (a) which Access Points and service providers operate in Sweden, (b) how the dominant Swedish accounting platforms (Fortnox, Visma, Bokio, SpeedLedger, Björn Lundén, Hogia) wire their Peppol layers, almost all white-label, (c) per-document pricing benchmarks, (d) the post-2022 industry consolidation pattern.
## Major Swedish Access Points and service providers
| Provider | Status (Apr 2026) | Segment | Reach (claimed) | Notes |
|---|---|---|---|---|
| **Pagero** | Acquired by **Thomson Reuters Feb 2024** (~SEK 8.1B / USD 800M); now branded "ONESOURCE Pagero" | Large enterprise / global multinationals + free SME tier | 90,000 customers, 14M-company network, 75+ countries | Net sales 2023 SEK 795M (+33%); EBITA SEK 17.4M. REST APIs (Document/Network/Signup/File/User). Quote-based pricing. Strong in global tax compliance. |
| **InExchange (Factorum AB)** | **Visma group since ~2020**, Skövde HQ | SME / mid-market dominant | ~80M tx/yr, 60,000 customers, 800,000+ orgs | Underlying operator for **Visma Autoinvoice** and Hantverksdata Entré. 12 invoices/yr free tier. Heaviest installed Swedish SME base. |
| **Crediflow AB** | PE-backed by **VIA Equity (2024)**; group includes OptoSweden, DocUp | Mid-market, ERP/system partner | 300,000+ companies monthly via 60+ ERPs | **Underlying AP for Fortnox, Bokio, SpeedLedger.** Group revenue ~SEK 100M. Most Swedish SaaS accounting depends on Crediflow. |
| **Visma Autoinvoice / Maventa** | Visma Group (KKR/HG/Cinven) | Captive Visma ecosystem | Routes through Maventa AP + InExchange | Cross-border via Peppol BIS 3; converts Svefaktura 1.0, TEAPPSXML, Finvoice 3.0, VismaXML, LiinosXML, SI-UBL. **API documentation:** https://documentation.autoinvoice.visma.com/ |
| **Tietoevry (BIX)** | Listed Finnish-Swedish | Banks, large enterprise, public sector | Certified Peppol AP since 2012 | White-labels to two of the largest Swedish banks. Strong B2C (Multichannel/Live Invoice). |
| **Basware** | **Take-private 2022** by Accel-KKR + Long Path + Briarwood (€620M, 94.7% premium) | Large global enterprise AP automation | 700+ global customers, 170M invoices/yr | HQ Helsinki/Espoo. Strong with Hogia. |
| **OpusCapita** | Acquired by **GEP (US) 1 July 2024** from PSG Equity | Mid/large enterprise, Nordics | 600 clients | Operates Skatteverket's free supplier portal. |
| **Ropo Capital** | Adelis Equity-backed; acquired Colligent Inkasso (2019), Posti Messaging Scandinavia (2020) | Large invoice volumes (energy, real estate, telco) | 11,000+ Nordic customers, 170M+ docs/yr | Combines invoicing + collections. |
| **Qvalia** | Swedish, Stockholm | SME, developer-friendly | 30+ countries, ISO 27001 | **Public, transparent EUR pricing** (see below). Best DX choice for an open-source integration. |
| **Hogia** | Swedish | Enterprise | Own Peppol AP | **Only major SE accounting vendor with its own Peppol AP** (others white-label). Uses Basware fakturaadresser for some units. |
| **Compello, Edicom, TrueCommerce, Tungsten Automation** | Various | Niche / enterprise / EDI |, | Minor Sweden footprint relative to top tier. |
| **Storecove** | Dutch | Developer-first, multi-mandate API | 30+ countries | Single REST API spans Peppol + DBNAlliance + local mandates (FR, IT SDI, BE, DE, PL KSeF, etc.). 30-day free sandbox. Strong fit for ERP/SaaS embedding. |
## Qvalia public pricing (EUR, as of April 2026)
| Tier | Monthly EUR | Messages/mo |
|---|---|---|
| Free | €0 | 1 |
| Small | €9 | 25 |
| Medium | €39 | 100 |
| Plus | €99 | 1,000 |
| Plus | €249 | 2,500 |
| Plus | €499 | 5,000 |
| Plus | €899 | 10,000+ |
Setup fee 0. Includes inbound + outbound Peppol BIS Billing 3 + EHF + multi-mandate routing. **Most transparent commercial pricing in the Swedish market**, useful as a benchmark.
## SME pricing benchmarks (April 2026)
| Item | Typical SME price (SEK) |
|---|---|
| Outbound e-invoice (sender) | **3.003.50** (Bokio, SpeedLedger, BL); 03.40 (InExchange) |
| Inbound e-invoice | Often free; 3.50 (BL) |
| Monthly minimum (SME platform) | 0199 |
| Setup/onboarding | 0 |
| Postal fallback | 8.2520 |
| Kivra to consumer | ~5 |
| Enterprise (Pagero/Basware) per-doc | Negotiated, EUR 0.100.50 |
## How Swedish accounting platforms wire Peppol
**The dominant SME accounting platforms in Sweden all white-label two intermediaries.** Fortnox, Bokio and SpeedLedger funnel through **Crediflow**. Visma eEkonomi/SPCS/Administration/Business funnel through **Visma Autoinvoice (Maventa)**, which is itself a certified Peppol AP. None operate their own Peppol AP, except Hogia.
This concentration is a strategic opening for any new platform: **building native Peppol disintermediates the entire value chain** and removes a markup baked into incumbent pricing.
### Fortnox
- Sweden's largest cloud ERP (~500,000+ customers).
- REST API at `https://api.fortnox.se/3/` (XML or JSON).
- Rate limit 25 req/5s/token.
- OAuth2 via `https://apps.fortnox.se/oauth-v1/`.
- Key endpoints: `/3/invoices`, `/3/supplierinvoices`, `/3/supplierinvoicepayments`, `/3/invoicepayments`, `/3/supplierinvoicefileconnections` (attach UBL/PDF), `/3/supplierinvoiceaccruals`, `/3/customers`, `/3/suppliers`, `/3/articles`, `/3/vouchers`, `/3/accounts`, `/3/financialyears`, `/3/taxreductions` (ROT/RUT), `/3/noxfinansinvoices` (factoring).
- WebSocket push API delivers `Invoices`/`SupplierInvoices`/`Vouchers`/`Customers` topics.
- Peppol-ID auto-published in Peppol Directory for AB customers.
- ROT/RUT housework supported natively (`HouseWork=true`, `HouseWorkType`, `HouseWorkHoursToReport`).
### Visma Autoinvoice / Maventa
- Cleanest Peppol developer story among Swedish platforms.
- API docs: https://documentation.autoinvoice.visma.com/
- REST and legacy SOAP, OAuth2 client-credentials.
- Operator routing flags: `PEPPOL`, `INEXCHANGE`, `NEMHANDEL`, `SCAN`, `BANK`, `B2CSE`, `VISMASCANNER`.
- The `lookup`/`finder` endpoint exposes SMP discovery.
- `POST /v1/services/b2cse/agreement` registers a sender on the Swedish bank-based B2C rail (e-faktura privat).
### Bokio
- Outbound UBL Peppol BIS Billing 3 at **3 SEK per invoice** via Crediflow.
- **B2B only** for outbound Peppol.
- Auto-flags year-end-crossing invoices.
- Provides "Konvertera till fakturametoden" UX, useful template for any Swedish bookkeeping product.
### Björn Lundén (BL Total / Lundify)
- **3.50 SEK per outbound and inbound** e-invoice.
### SpeedLedger
- Bank-feed centric.
- **3 SEK per sent**.
### Hogia
- The only major SE accounting vendor with its own Peppol AP.
- Owns relationships with several mid-market customers.
## Industry consolidation post-2022
Pattern: **bigger players are bundling tax + invoicing + AP automation + compliance reporting for enterprise**, leaving the Swedish SME and redovisningsbyrå segment as fertile ground.
| Year | Event |
|---|---|
| 2022 | Basware take-private by Accel-KKR + Long Path + Briarwood, €620M, 94.7% premium |
| Feb 2024 | Thomson Reuters acquires Pagero, ~SEK 8.1B / USD 800M |
| 2024 | VIA Equity acquires Crediflow group |
| 1 Jul 2024 | GEP (US) acquires OpusCapita from PSG Equity |
| ~2020 | Visma Group acquires InExchange (Factorum AB) |
| 20192020 | Ropo Capital acquires Colligent Inkasso, Posti Messaging Scandinavia |
**Reseller dependency risk is now real**, Pagero is a TR division, Basware is PE-owned, both will eventually re-price upward. Build optionality into the stack via a clean `SendClient` abstraction with dual-vendor capability.
## Choosing an Access Point partner, decision framework
For a new Swedish accounting/fintech product, the practical menu:
1. **Storecove**, best DX, single API across Peppol + IT SDI + FR PA + PL KSeF + BE + DE. Free 30-day sandbox. Effective rate €0.05–€0.30/invoice. **Best fit when multi-mandate is needed.**
2. **Qvalia**, transparent EUR pricing, ISO 27001, Swedish-headquartered. Good fit for SME-only Swedish flows.
3. **InExchange**, heaviest Swedish installed base, but partial vendor lock-in via Visma Group ownership.
4. **Visma Autoinvoice / Maventa**, only worth it if already integrating Visma ecosystem.
5. **Pagero / Basware**, enterprise-only, not SME-friendly post-acquisition.
6. **Own Oxalis-NG AP**, only for receive at low volume (€100/mo Hetzner + €4,400 yr-1 OpenPeppol fees). For send, only economic above ~25k invoices/month.
**Build vs buy break-even:** reseller cost of ~€0.10/invoice (mid-volume Storecove) versus own-AP fixed cost of ~€30k/year (membership + cert + ops) breaks even at **~300,000 invoices/year sent through your customers**, equivalent to ~3,0005,000 active SME customers each sending 50100 invoices/month. **For receive there is no economic case to build initially**, Storecove free receive or own Oxalis on a small VPS dominate.
## Authoritative source list
- DIGG Peppol traffic stats: https://www.digg.se/digitala-tjanster/peppol/statistik-fran-peppolnatverket-
- Visma Autoinvoice docs: https://documentation.autoinvoice.visma.com/
- Fortnox API: https://developer.fortnox.se/
- Pagero compliance pages: https://www.pagero.com/compliance/regulatory-updates/sweden
- Storecove blog/docs: https://www.storecove.com
- Qvalia pricing: https://qvalia.com/pricing/
- InExchange knowledge base: https://inexchange.com/en/discover
- Skatteverket on B2G e-faktura: https://skatteverket.se/omoss/varverksamhet/forleverantorer/efakturortillskatteverket.4.b1014b415f3321c0de2680.html
@@ -0,0 +1,241 @@
# Peppol BIS Billing 3.0, Wire Format Reference
## The BIS suite and document identifiers
Peppol BIS Billing 3.0 is identified by:
```
cbc:CustomizationID = urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0
cbc:ProfileID = urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
```
UBL roots are `Invoice` (`urn:oasis:names:specification:ubl:schema:xsd:Invoice-2`) and `CreditNote` (`urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2`). The current Peppol POACC release is **Billing 3.0.20 (November 2025)**.
The wider BIS 3.0 suite (Peppol Post-Award and adjacent profiles):
| Profile | Process ID suffix | Purpose |
|---|---|---|
| Billing 3 | `billing:01:1.0` | Standard B2B/B2G invoice + credit note |
| Self-Billing 3 | `selfbilling:01:1.0` | Buyer issues invoice on supplier's behalf |
| Order Only / Ordering / Advanced Ordering / Order Agreement | `ordering:*` | Procurement order flows |
| Despatch Advice 3 | `despatchadvice:01:1.0` | Shipment notification |
| Catalogue 3 | `catalogue:01:1.0` | Product catalogue exchange |
| Invoice Response (IMR, T111) | `invoiceresponse:01:1.0` | Buyer accepts/rejects invoice |
| Message Level Response (MLR, T71) | `mlr:01:1.0` | Technical receipt confirmation |
| **Message Level Status (MLS)** | `urn:peppol:edec:mls:1.0` | New: machine-readable processing status |
**Peppol BIS 4.0 / PINT convergence** is announced for late 2025 / early 2026, track release notes at https://docs.peppol.eu/poacc/billing/3.0/bis/ before locking long-lived schemas.
InvoiceTypeCode (BT-3) restricted set: `380` commercial invoice, `381` credit note (Invoice), `384` corrected invoice, `389` self-billed. CreditNoteTypeCode: `381` credit note, `396` factored credit note, `261` self-billed credit note.
## Mandatory header
```xml
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>INV-2026-00001</cbc:ID> <!-- BT-1 -->
<cbc:IssueDate>2026-04-27</cbc:IssueDate> <!-- BT-2, ISO 8601 -->
<cbc:DueDate>2026-05-27</cbc:DueDate> <!-- BT-9 -->
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode> <!-- BT-3 -->
<cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode> <!-- BT-5 -->
<cbc:BuyerReference>SE-DEPT-42</cbc:BuyerReference> <!-- BT-10, mandatory if no OrderReference -->
...
</Invoice>
```
## Party blocks
`AccountingSupplierParty` and `AccountingCustomerParty` carry:
- `cbc:EndpointID` (BT-34, mandatory `schemeID`), the Peppol routing address.
- `cac:PartyIdentification/cbc:ID`, additional business identifiers (GLN, etc.).
- `cac:PartyName/cbc:Name`.
- `cac:PostalAddress`, `StreetName`, `CityName`, `PostalZone`, `Country/IdentificationCode`.
- `cac:PartyTaxScheme/cbc:CompanyID`, VAT identifier.
- `cac:PartyLegalEntity/cbc:RegistrationName` and `cbc:CompanyID`.
- `cac:Contact`.
For Swedish entities see `swedish-cius-and-specifics.md` for the F-skatt rule, orgnr formats, and VAT prefix requirements.
## VAT category codes (UNCL5305)
Used in `cac:ClassifiedTaxCategory/cbc:ID` per line and `cac:TaxCategory/cbc:ID` in summary blocks.
| Code | Meaning | Rate | TaxExemptionReason |
|---|---|---|---|
| **S** | Standard rate | >0 | No |
| **Z** | Zero rated | 0 | No |
| **E** | Exempt from VAT | 0 | Yes (BT-120/BT-121) |
| **AE** | Reverse charge | 0 | Yes (`VATEX-EU-AE`) |
| **K** | Intra-EU supply of goods/services | 0 | Yes (`VATEX-EU-IC`) |
| **G** | Export outside EU | 0 | Yes (`VATEX-EU-G`) |
| **O** | Outside scope of VAT | n/a | Yes (`VATEX-EU-O`) |
| **L** | IGIC (Canary Islands) | various | Spanish use |
| **M** | IPSI (Ceuta/Melilla) | various | Spanish use |
Reverse charge example (Sweden→EU customer):
```xml
<cac:ClassifiedTaxCategory>
<cbc:ID>AE</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:ClassifiedTaxCategory>
```
In the summary `TaxSubtotal`:
```xml
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="SEK">10000.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="SEK">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>AE</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cbc:TaxExemptionReasonCode>VATEX-EU-AE</cbc:TaxExemptionReasonCode>
<cbc:TaxExemptionReason>Reverse charge</cbc:TaxExemptionReason>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
```
## Multi-currency handling
`DocumentCurrencyCode` (BT-5) drives all amounts. If it differs from the seller's VAT accounting currency:
- Add `cbc:TaxCurrencyCode` (BT-6).
- Add a second `cac:TaxTotal` carrying only the SEK-equivalent total tax (BT-111).
```xml
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SEK</cbc:TaxCurrencyCode>
...
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">2500.00</cbc:TaxAmount>
<cac:TaxSubtotal>...</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="SEK">28750.00</cbc:TaxAmount>
</cac:TaxTotal>
```
## Calculation rules, the failure surface
These are the rules that cause the majority of production rejections.
| Rule | Constraint |
|---|---|
| **BR-CO-15** | `BT-112 = BT-109 + BT-110` (TaxInclusiveAmount = TaxExclusive + Tax). Canonical rounding bug. |
| **BR-CO-13** | `BT-109 = BT-106 BT-107 + BT-108` (TaxExclusive = LineTotal Allowances + Charges) |
| **BR-CO-17** | `BT-117 = round2(BT-116 × BT-119 / 100)` (per category tax = taxable × rate) |
| **BR-S-08, BR-Z-08, BR-E-08, BR-AE-08, BR-IC-08, BR-G-08, BR-O-08** | Per-category taxable amount must reconcile to corresponding line totals + charges allowances |
| **BR-CO-9** | VAT identifier must start with country code prefix |
| **BR-S-01 / BR-Z-01 / etc.** | At least one line, allowance or charge per used VAT category |
| **PEPPOL-EN16931-R053** | Only one tax total without currency suffix |
**Always do arithmetic in BigDecimal/Decimal with explicit scale.** Round at the boundary, never in intermediate steps. Force `Locale.ROOT` / `InvariantCulture` for serialisation. Document totals must be exactly 2 decimals; price amounts (BT-146/BT-148) are unbounded.
Belgium's 2026 mandate disallows line-by-line VAT rounding; Sweden currently allows it but may not for long.
## Allowances and charges
Document level via `cac:AllowanceCharge`:
```xml
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator> <!-- false = allowance, true = charge -->
<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode> <!-- UNCL5189 / UNCL7161 -->
<cbc:AllowanceChargeReason>Volume rebate</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="SEK">100.00</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
```
UNCL5189 reason codes (allowance) include: 41 Bonus for works ahead of schedule, 60 Manufacturer's consumer discount, 95 Discount, 100 Special rebate, 102 Fixed long term, 103 Temporary, 104 Standard. UNCL7161 reason codes (charge) include: AA Advertising, AAA Telecommunication, ABK Miscellaneous, FC Freight charge, IN Insurance, SH Shipping and handling.
## Line items
```xml
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="SEK">5000.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Consulting hours</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="SEK">500.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
```
`unitCode` follows UN/ECE Recommendation 20 (`EA` each, `HUR` hour, `KGM` kilogram, `MTR` metre, `LTR` litre, `DAY` day, `MON` month, `C62` "one", used for dimensionless services).
## Document totals
```xml
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="SEK">10000.00</cbc:LineExtensionAmount> <!-- BT-106 -->
<cbc:TaxExclusiveAmount currencyID="SEK">9900.00</cbc:TaxExclusiveAmount> <!-- BT-109 -->
<cbc:TaxInclusiveAmount currencyID="SEK">12375.00</cbc:TaxInclusiveAmount> <!-- BT-112 -->
<cbc:AllowanceTotalAmount currencyID="SEK">100.00</cbc:AllowanceTotalAmount> <!-- BT-107 -->
<cbc:ChargeTotalAmount currencyID="SEK">0.00</cbc:ChargeTotalAmount> <!-- BT-108 -->
<cbc:PrepaidAmount currencyID="SEK">0.00</cbc:PrepaidAmount> <!-- BT-113 -->
<cbc:PayableRoundingAmount currencyID="SEK">0.00</cbc:PayableRoundingAmount> <!-- BT-114 -->
<cbc:PayableAmount currencyID="SEK">12375.00</cbc:PayableAmount> <!-- BT-115 -->
</cac:LegalMonetaryTotal>
```
## Attachments
Via `cac:AdditionalDocumentReference` with `cac:Attachment/cbc:EmbeddedDocumentBinaryObject` (base64) or `cac:ExternalReference/cbc:URI`. Inline attachments should stay under 10 MB despite the formal 100 MB AS4 ceiling, many access points reject larger payloads in practice.
```xml
<cac:AdditionalDocumentReference>
<cbc:ID>timesheet-april-2026.pdf</cbc:ID>
<cbc:DocumentDescription>Detailed timesheet</cbc:DocumentDescription>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf"
filename="timesheet-april-2026.pdf">JVBERi0xLjQK...</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
```
## Validation stack
Three layers in this order:
1. **XSD**, UBL 2.1 schemas. Catches structural errors. Source: https://docs.oasis-open.org/ubl/os-UBL-2.1/UBL-2.1.html
2. **EN 16931 Schematron**, CEN/TC 434 official artefacts (`EN16931-UBL-validation.sch`). Catches BR-* business rules and BR-CO-* calculation rules.
3. **`PEPPOL-EN16931-UBL.sch`**, Peppol overlay with country-specific rules. SE-R-* (Sweden), NO-R-* (Norway), IT-R-* (Italy), DE-R-* (Germany), NL-R-* (Netherlands), DK-R-* (Denmark).
Authoritative GitHub repo: **https://github.com/OpenPEPPOL/peppol-bis-invoice-3** with releases on a May/November cadence.
Validators to integrate:
- **Helger Peppol Practical**, https://peppol.helger.com, REST + UI, runs phive-rules.
- **Storecove peppolvalidator.com**, https://peppolvalidator.com, error code lookup.
- **EC DG GROW eInvoicing validator**, https://itb.ec.europa.eu/invoice, official EC validator.
- **Norwegian validator**, https://anskaffelser.no/verktoy/validator (also useful for Sweden).
- **DIGG testbädd**, https://www.digg.se/digitala-tjanster/peppol/peppol-testbadd
Validate at **three points** in your pipeline: (1) immediately after UBL generation locally; (2) before handoff to the Access Point; (3) on receive in your inbound flow before bookkeeping. Many AP rejections happen post-send when the receiver's MLR comes back hours later.
## Authoritative source list
- Peppol BIS Billing 3.0 specification: https://docs.peppol.eu/poacc/billing/3.0/bis/
- UBL 2.1 syntax tree: https://docs.peppol.eu/poacc/billing/3.0/syntax/ubl-invoice/
- Self-billing: https://docs.peppol.eu/poacc/self-billing/3.0/bis-sb/
- Peppol document types index: https://www.peppol.nu/knowledge-base/peppol-document-types-standards/
- OpenPEPPOL repo: https://github.com/OpenPEPPOL/peppol-bis-invoice-3
- Validator with error code lookup: https://peppolvalidator.com/peppol-validation-errors
@@ -0,0 +1,172 @@
# Peppol Network Architecture, AS4, SMP, SML, PKI
## The four-corner model
Standard Peppol routing:
```
[C1 Sender] → [C2 Sending AP] → [C3 Receiving AP] → [C4 Receiver]
ERP ERP
─── Peppol network ───
```
Only **C2↔C3** is on-network. C1↔C2 and C3↔C4 are local integrations (REST API, SFTP, file watcher, ERP plugin) chosen by each AP. Sending and receiving APs may belong to different organisations or to the same provider; they may also be the same AP (intra-network delivery).
ViDA introduces the **five-corner model** for cross-border B2B reporting from 1 July 2030: the tax administration becomes Corner 5 receiving DRR data in parallel with C3.
## SML, Service Metadata Locator
The SML is the centralised DNS service. Operated by **OpenPeppol AISBL** (insourced from EC DG DIGIT during 20242025).
Lookup algorithm (migrated from CNAME/MD5 to **NAPTR/SHA-256** during 2025):
```
domain = base32(sha-256(lowercase(<scheme>::<value>))).iso6523-actorid-upis.<DNSZONE>
```
Production zone: `edelivery.tech.ec.europa.eu`
Test zone: `acc.edelivery.tech.ec.europa.eu`
DNS NAPTR record returns the SMP base URL.
Example for DIGG (`0007:2021006883`):
```
sha256("iso6523-actorid-upis::0007:2021006883") = ...
base32(hash) = b-eepvcndgxw5tjr...
NAPTR query: b-eepvcndgxw5tjr....iso6523-actorid-upis.edelivery.tech.ec.europa.eu
```
## SMP, Service Metadata Publisher
The SMP is queried per **Peppol SMP specification v1.3.0 (February 2025)** for `ServiceGroup` (lists of supported document types) and `SignedServiceMetadata` (specific endpoint metadata, signed XML-DSIG). Endpoints expose:
- `GET /<participant-id>`, ServiceGroup (list of document types).
- `GET /<participant-id>/services/<doc-type-id>`, SignedServiceMetadata (endpoint URL, AP certificate, transport profile, validity period).
Transport profile is now **`peppol-transport-as4-v2_0`** for production (replaced the v1 profile in 2020).
A given participant can be registered with **only one SMP at a time**. Migrating between APs requires the new AP to register the participant in its SMP and the old AP to deregister.
## AS4 transport
**Peppol AS4 Profile v2.0.x** is a profile of CEF eDelivery AS4 v1.14, which is itself a profile of OASIS ebMS3.
Wire characteristics:
- HTTPS, TLS 1.2+ (TLS 1.3 supported).
- MIME multipart with single encrypted payload.
- WS-Security message-level signing (RSA-SHA256) using the sender's AP certificate.
- Encryption (AES-128-GCM or AES-256-GCM) using the recipient AP's certificate fetched from the SMP.
- Single payload per AS4 message wrapping the **SBDH (Standard Business Document Header) v1.2** / **Peppol Business Message Envelope 2.0** which itself wraps the UBL document.
**SBDH C1 country code mandatory since January 2024.** Oxalis 6.2.0+, Helger phase4 latest, and other compliant stacks enforce this. The SBDH carries `Sender`, `Receiver`, `DocumentIdentification` (standard, type version, instance ID), `BusinessScope` (process ID, document type ID, **C1 country code**).
**From 1 February 2026**, SMP servers must run HTTPS on port 443 under the Peppol Policy for Transport Security.
Authoritative spec: https://docs.peppol.eu/edelivery/as4/specification/
## PKI, G2 to G3 migration
The Peppol PKI migrated from **G2 (issued by IHC) to G3 (DigiCert One Trust Lifecycle)** during H2 2025. **G3-only after end-2025**, any test/production cert issued from 2026 onwards is G3.
Two certificate types per Access Point:
- **AP cert**, used for AS4 message signing and encryption.
- **SMP cert**, used to sign `SignedServiceMetadata` responses.
Cert validity is typically 12 years. Renewal is automated via DigiCert's portal; trust store updates flow via OpenPeppol member announcements.
Trust store libraries (Helger `peppol-commons`, Oxalis) ship the bundled Peppol root and intermediate certs; update at least quarterly to track CA rotation.
Issuance and enrolment process: https://openpeppol.atlassian.net/wiki/spaces/OPMA/pages/4439080961/Peppol+PKI+2025+-+Issuing+and+Enrolment+Process
## Identifier schemes (ICD / EAS codes)
Used as `schemeID` on `cbc:EndpointID`, `cac:PartyIdentification/cbc:ID`, etc.
| Code | Authority | Use |
|---|---|---|
| **0007** | Bolagsverket organisationsnummer | Swedish primary |
| **0088** | GS1 GLN | Swedish large orgs; recommended for sole proprietors (GDPR) |
| **0192** | Norwegian Enhetsregisteret | Replaces deprecated `9908` |
| **0184** | Danish CVR | Danish primary |
| **0037** | Finnish LY-tunnus | Finnish primary |
| **0208** | Belgian KBO/BCE | Belgian primary |
| **0204** | German Leitweg-ID | German B2G mandatory |
| **9930** | German VAT-ID | German B2B |
| **0009** | French SIRET | French primary |
| **0211** | Italian CodiceIPA | Italian B2G |
| **0213** | Italian CodiceFiscale | Italian B2C |
| **0096** | Dutch OIN | Dutch government |
Authoritative live list: https://docs.peppol.eu/edelivery/codelists/ and https://docs.peppol.eu/poacc/billing/3.0/codelist/eas/.
GitHub: https://github.com/OpenPEPPOL/peppol-bis-invoice-3/blob/master/structure/codelist/eas.xml
## Becoming a Peppol Access Point, the operational path
### OpenPeppol membership fees (effective 1 July 2025 for new members)
For a small Swedish fintech (S1 size, 110 employees):
| Path | Sign-up | Annual | Certification | Year-1 total |
|---|---|---|---|---|
| **AP + SMP S1** | €1,800 | €2,750 | €2,500 | **≈ €7,050** |
| **AP-only S1+S2** | €1,050 | €1,850 | €1,500 | **≈ €4,400** |
| **End User S1+S2** | €650 | €1,250 | n/a | **≈ €1,900** |
Add infrastructure cost: 24/7 redundant AS4 hosting (€310k/yr), monitoring/on-call (€520k fully loaded), DigiCert G3 certs (bundled into OpenPeppol annual). DIGG charges no additional Peppol-Authority fee for Swedish service providers but requires signing the **Peppol Service Provider Agreement**.
**Realistic minimum to operate an own AP: €2040k/year direct cost plus 0.51 FTE engineering and 36 months upfront build.** Twice-yearly spec updates with a 7-day implementation window and mandatory monthly volume reporting are non-trivial recurring costs.
### Onboarding steps
1. Submit candidate application to `membership@peppol.eu`.
2. Sign the **Peppol Member Agreement** with OpenPeppol AISBL.
3. Sign the **Peppol Service Provider Agreement** (formerly Transport Infrastructure Agreement, TIA) with **DIGG** as Sweden's Peppol Authority.
4. Implement AS4 + SMP lookup + SBDH handling. Deploy in test environment.
5. Request DigiCert One G3 test certificate.
6. Pass the **Peppol Testbed conformance suite**, refactored 2025 with country-specific payload tests. https://peppol.org/tools-support/testbed/
7. Pay the Certification Fee.
8. Production certificate issued.
9. Register SMP in production SML via DIGG.
10. Commit to monthly volume reporting.
**Total elapsed time: 36 months.**
### Open-source AS4 / SMP stacks
- **Oxalis-NG** (https://github.com/OxalisCommunity/oxalis-ng), replaces Oxalis 6.x which is end-of-life Dec 2025. Java, Apache 2.0.
- **Oxalis-AS4 7.x** (https://github.com/OxalisCommunity/oxalis-as4).
- **Helger phase4** (https://github.com/phax/phase4), Apache 2.0 AS4 client + server.
- **Helger phoss-smp**, production-grade SMP server.
- **Helger peppol-commons**, identifiers, codelists, SBDH, SMP/SML clients.
- **Helger phive + phive-rules**, validation engine and pre-built rules.
- **Helger ph-ubl, ph-cii, ph-sbdh**, JAXB models.
- **phase4-peppol-standalone**, Spring Boot 3 reference implementation (template, not turn-key).
Trust stores updated to G3-only late 2025. Verify version when integrating.
## DIGG Peppol traffic statistics (Q4 2025)
- October 2025: record **5,668,209 Peppol messages** to Sweden.
- November 2025: 4,810,015.
- December 2025: 5,190,513.
- Volume growth Sept 2024 → Sept 2025: **+30%**.
- **25,000+ Swedish Peppol receivers** registered.
- DIGG public sector survey (March 2025): 82% of public sector inbound invoices are e-invoices, 50% of outbound (up from 24%), 85% of public sector orgs use Peppol fully/largely for inbound.
- Bankföreningen reports 168.9M e-invoices to consumers in 2024.
- Total Swedish e-invoice volume estimate: **~250M/year** combining bank rails, Peppol B2G/B2B, and residual non-Peppol flows.
Live stats: https://www.digg.se/digitala-tjanster/peppol/statistik-fran-peppolnatverket-
## Authoritative source list
- Peppol AS4 spec: https://docs.peppol.eu/edelivery/as4/specification/
- Peppol SMP spec: https://docs.peppol.eu/edelivery/smp/specification/
- SMP/SML interplay (Helger): https://peppol.helger.com/public/menuitem-docs-smp-sml-interplay
- Setup AP guide: https://peppol.helger.com/public/menuitem-docs-setup-ap
- Setup phoss SMP: https://peppol.helger.com/public/menuitem-docs-setup-smp-ph
- Peppol Testbed: https://peppol.org/tools-support/testbed/
- OpenPeppol membership: https://peppol.eu/who-is-who/openpeppol-membership/
- DIGG how Peppol works: https://www.digg.se/digitala-tjanster/peppol/sa-fungerar-peppol-
- Identifier policy: https://docs.peppol.eu/edelivery/codelists/
@@ -0,0 +1,315 @@
# Sweden CIUS Rules and Accounting Integration
This reference covers everything that is **specifically Swedish** in a Peppol BIS Billing 3 invoice: identifier formats, the SE-R-* validation rules, VAT rate handling, payment encoding for Bankgiro/Plusgiro/OCR, ROT/RUT and grön teknik handling, BAS-kontoplan postings, faktureringsmetoden vs kontantmetoden, and per-buyer BT-10 BuyerReference formats.
## Swedish identifier formats
### EndpointID (BT-34)
```xml
<cbc:EndpointID schemeID="0007">5567321000</cbc:EndpointID>
```
- **Swedish orgnr** → `schemeID="0007"`, **10 digits, no dash**. Example: `5567321000` (not `556732-1000`).
- **GLN** → `schemeID="0088"`, 13 digits. Used by larger organisations and DIGG-recommended for sole proprietors to avoid exposing personnummer.
- **Sole proprietor with personnummer** → `schemeID="0007"` with the personnummer as a 10-digit orgnr (since personnummer IS the firma's orgnr). **GDPR concern**: routing metadata is visible across the Peppol network. DIGG recommends switching to GLN.
### VAT identifier
`cac:PartyTaxScheme/cbc:CompanyID` for a Swedish entity:
```xml
<cac:PartyTaxScheme>
<cbc:CompanyID>SE556732100001</cbc:CompanyID>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:PartyTaxScheme>
```
Format: **`SE` + 10 digits (orgnr) + `01`** (the trailing `01` is the legal sequence number, almost always `01`). The country prefix is mandatory under **BR-CO-9**.
### Legal entity registration
```xml
<cac:PartyLegalEntity>
<cbc:RegistrationName>Arcim Technology AB</cbc:RegistrationName>
<cbc:CompanyID schemeID="0007">5567321000</cbc:CompanyID>
<cbc:CompanyLegalForm>Godkänd för F-skatt</cbc:CompanyLegalForm>
</cac:PartyLegalEntity>
```
## SE-R-*, the Sweden CIUS rules
### SE-R-005 (FATAL), F-skatt declaration
If a Swedish supplier issues an invoice with VAT category `S`, **the literal string "Godkänd för F-skatt" must appear somewhere in the invoice**. Standard placements:
- `cac:PartyLegalEntity/cbc:CompanyLegalForm` (preferred)
- A document-level `cbc:Note`
- An invoice-line `cbc:Note`
This is the **single most common reason public sector authorities reject Swedish invoices**. Hard-code the string into the UBL template; do not derive it from a database flag (the F-skatt status of an active Swedish AB is universal).
### SE-R-006, Swedish VAT rate restriction
If supplier VAT country is `SE` and category is `S`, the rate must be **6, 12, or 25**. Any other rate must use category `E` (exempt) with an exemption reason, typically used for the rare zero-rate cases that are technically exempt rather than zero-rated.
### SE-R-008 / SE-R-009, Bankgiro
Bankgiro account numbers must be **78 numeric digits**.
### SE-R-010, Plusgiro
Plusgiro account numbers must be **28 characters**.
### SE-R-011, Swedish payment methods
PaymentMeansCode `30` (Credit transfer) is mandatory for both Bankgiro and Plusgiro. **Legacy codes 56 and 50 are explicitly forbidden.** The discriminator between Bankgiro and Plusgiro is `cac:FinancialInstitutionBranch/cbc:ID`:
- `SE:BANKGIRO` for Bankgiro
- `SE:PLUSGIRO` for Plusgiro
### SE-R-013, Luhn validity on orgnr
Swedish organisationsnummer must pass the modulus-10 (Luhn) check. Implementation note: the check digit is computed over the first 9 digits.
## Swedish payment encoding (full example)
Bankgiro payment with OCR reference:
```xml
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>1234567890123</cbc:PaymentID> <!-- BT-83 OCR reference -->
<cac:PayeeFinancialAccount>
<cbc:ID>5555-1234</cbc:ID>
<cbc:Name>Arcim Technology AB</cbc:Name>
<cac:FinancialInstitutionBranch>
<cbc:ID>SE:BANKGIRO</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
```
Plusgiro:
```xml
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>1234567890123</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>123456-7</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>SE:PLUSGIRO</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
```
For non-Swedish EU payments use code `58` (SEPA credit transfer) with country-checked IBAN in `cbc:ID`.
## OCR references
Swedish OCR is numeric, **225 digits, last digit modulus-10 (Luhn) check**. Optional length digit at position n1 encodes total length.
Four control levels at Bankgirot:
- **OCR1**, soft (mjuk), Luhn only.
- **OCR2**, hard (hård), Luhn + reference exists in BG receiver's reference register.
- **OCR3**, fixed length via length digit.
- **OCR4**, fixed length, up to 3 lengths allowed.
Hård kontroll causes internet-banks to reject mismatched OCR, test thoroughly before going live. Encode in `cbc:PaymentID` (BT-83).
## Bankgirot integration mechanics
### Inbound payment file (kundreskontra)
Bankgirot's daily inbound file uses fixed-format records with TK (transaction code):
| TK | Meaning |
|---|---|
| 05 | Inbetalning |
| 15 | Avdrag/return |
| 25 | Inbetalning utan referens |
| 27 / 28 / 29 | Bg/Pg variations |
| 70 | Justering |
The OCR (BT-83 / `cbc:PaymentID`) is the match key driving automatic invoice closure in 1510 Kundfordringar.
### Outbound LB-rutin (leverantörsbetalningar)
Outbound payment file uses TK 11/14/16/17/25/26/27/29/49 records, supports Girering, Kontoinsättning (clearing+kontonr), Kontantutbetalning and Avräkning av kreditfaktura. Signed and submitted via the company's bank (BgCom / BankgiroLink). The återredovisningsfil drives AP closure in 2440 Leverantörsskulder.
**P27 was abandoned in 2023.** Banks have introduced bank-specific ISO 20022 PAIN.001/.002 flows. **Plan for LB-rutin and ISO 20022 to coexist for the foreseeable future.**
## VAT (moms) rates and category encoding
Swedish rates: **25% standard, 12% (food, hotel, restaurant), 6% (books, transport, culture, sport), 0%**.
Standard 25% line example:
```xml
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:ClassifiedTaxCategory>
```
Reverse charge for byggtjänster (omvänd skattskyldighet bygg, ML 2 kap. 1 § p. 4) within Sweden, uses `AE`:
```xml
<cac:ClassifiedTaxCategory>
<cbc:ID>AE</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:ClassifiedTaxCategory>
```
Buyer's bookkeeping must post 2647 Ingående moms omvänd skattskyldighet (DR) and 2614 Utgående moms omvänd skattskyldighet (CR) when receiving such an invoice.
Intra-EU service to a B2B EU customer, uses `K` (sometimes `AE` depending on supply rule):
```xml
<cac:ClassifiedTaxCategory>
<cbc:ID>K</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:ClassifiedTaxCategory>
```
With `VATEX-EU-IC` exemption reason in the summary `TaxCategory`.
## BAS-kontoplan postings
The de-facto chart is **BAS-kontoplanen** (https://www.bas.se), updated yearly. Key accounts for e-invoice flows:
### Customer-side (kundfakturor)
- **1510** Kundfordringar (AR control, primary)
- **1513** Kundfordringar i delbetalning (used for ROT/RUT split)
- **1515** Osäkra kundfordringar
- **1518** Ej reskontraförda kundfordringar
- **1519** Värdereglering kundfordringar
- **3001/3002/3003/3004** Försäljning by VAT rate (25/12/6/0%)
- **3308** Försäljning tjänst utanför EU
- **3573** Försäljning tjänst EU (för uppgift i periodisk sammanställning)
### Supplier-side (leverantörsfakturor)
- **2440** Leverantörsskulder (AP control, primary)
- **2445** Tvistiga leverantörsskulder
- **2448** Ej reskontraförda leverantörsskulder
- **4010** Inköp varor
- **4515** Inköp tjänster
- **4xxx** General cost-of-goods accounts
### VAT control
- **2611** Utgående moms 25%
- **2621** Utgående moms 12%
- **2631** Utgående moms 6%
- **2614** Utgående moms omvänd skattskyldighet
- **2615** Utgående moms 25% EU varuinköp
- **2641** Ingående moms (inland)
- **2645** Ingående moms utländska leverantörer
- **2647** Ingående moms omvänd skattskyldighet (e.g. byggmoms)
- **2650** Redovisningskonto för moms (clearing to Skatteverket)
### Cash and bank
- **1910** Kassa
- **1920** Plusgiro
- **1930** Företagskonto / Bank
## Standard postings
**Outbound invoice 1,000 SEK net + 25% VAT to Swedish customer (faktureringsmetoden):**
```
DR 1510 Kundfordringar 1 250
CR 3001 Försäljning 25% 1 000
CR 2611 Utgående moms 25% 250
```
**Inbound supplier invoice 1,000 SEK net + 25% VAT (faktureringsmetoden):**
```
DR 4010 Inköp 1 000
DR 2641 Ingående moms 25% 250
CR 2440 Leverantörsskulder 1 250
```
**Inbound construction reverse-charge supplier invoice 1,000 SEK:**
```
DR 4xxx Inköp byggtjänst 1 000
DR 2647 Ingående moms omvänd 250 (reclaimable)
CR 2440 Leverantörsskulder 1 000
CR 2614 Utgående moms omvänd 250 (collected)
```
Net effect on moms is zero, but both legs must be reported in momsdeklarationen.
## Faktureringsmetoden vs kontantmetoden
- **Faktureringsmetoden**, mandatory if turnover > **SEK 3 million**, default for AB. Books invoices on issuance against 1510/2440. Moms recognised on invoice date.
- **Kontantmetoden**, books only on payment, against 1930/1910. Year-end conversion mandatory: any outstanding kund-/leverantörsfaktura must be booked over to 1510/2440 and the underlying intäkt/kostnad recognised, then reversed on 1 Jan.
The bookkeeping engine must carry a `bookkeeping_method` flag driving posting timing and a year-end converter routine.
## ROT/RUT and grön teknik
**Peppol BIS Billing 3 has no standardised ROT/RUT extension.** Production practice in Swedish ERPs:
1. **On the invoice**: show a reduced "Att betala" amount (after Skatteverket's portion).
2. **Surface the deduction** as an `cac:AllowanceCharge` with informative reason (no standardised reason code, use `cbc:AllowanceChargeReason` text).
3. **Persist housework metadata** locally against Skatteverket's typkoder:
- **ROT-bygg**: Bygg, El, Glas/Plåt, Mark/Dränering, Murning, Målning/Tapetsering, VVS.
- **RUT**: Städning, Trädgård, Barnpassning, Övriga (incl. flytt, IT-arbete, snöskottning).
- **Grön teknik**: värmepump-schablon, solceller, lagring, laddpunkt.
4. **After payment**, generate Skatteverket's separate "Begäran om utbetalning" XML (`HUSXML`) and upload via Skatteverket's e-tjänst.
Bookkeeping splits the customer receivable into customer-paid and Skatteverket-receivable portions:
```
DR 1510 Kundfordringar (kund) 500 (kund's net to pay)
DR 1684 Skatteverket ROT-fordran 500 (or 1513 / 1689)
CR 3001 Försäljning 25% 1 000
CR 2611 Utgående moms 25% ...
```
The 1684/1513/1689 receivable is closed when Skatteverket disburses to the company's bank.
## BT-10 BuyerReference per-buyer formats
Each Swedish public sector buyer specifies its own BT-10 / BuyerReference format. Examples:
| Buyer | Format |
|---|---|
| Skatteverket | 4-letter code |
| Svenska Kraftnät | 3 digits + 3 letters |
| Försäkringskassan | 510 digits starting with `4` |
| Many universities | cost-centre + name |
**Maintain a per-buyer-Peppol-ID regex map** and validate at invoice compose time. This prevents 30-day public-sector payment delays. Scrape SFTI / docplayer reference and update quarterly.
## Multi-currency (Swedish supplier invoicing in EUR)
Swedish supplier issuing in EUR to an EU customer; book the SEK equivalent for VAT.
```xml
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SEK</cbc:TaxCurrencyCode>
```
Bookkeeping uses the **monthly average ECB rate** for the previous month (Skatteverket-accepted), or the day's spot rate if the contract specifies. Both rate sources are valid; document the policy.
## Authoritative source list
- BAS-kontoplan: https://www.bas.se
- SFTI Peppol BIS Billing 3: https://sfti.se/sfti/standarder/peppolbisehandel/peppolbisbilling3.49021.html
- Swedish payment methods in BIS 3: https://support.inexchange.com/hc/en-us/articles/360001888178-Swedish-Payment-Methods-in-PEPPOL-BIS-3
- DIGG Peppol-ID instructions: https://www.digg.se/digitala-tjanster/peppol/instruktion-for-val-av-peppol-id-
- SE-R-005: https://docs.peppol.eu/poacc/billing/3.0/rules/ubl-peppol/SE-R-005/
- SE-R-011: https://docs.peppol.eu/poacc/billing/3.0/rules/ubl-peppol/SE-R-011/
- Skatteverket ROT/RUT XML: https://www.skatteverket.se/foretag/rotochrutarbete (HUSXML schema)
@@ -123,6 +123,23 @@ describe('GET /api/bookkeeping/journal-entries', () => {
expect(body.count).toBe(2)
})
it('forwards explicit ?status=cancelled to the RPC', async () => {
enqueue({ data: [], error: null })
const request = createMockRequest('/api/bookkeeping/journal-entries', {
searchParams: { period_id: 'period-1', status: 'cancelled' },
})
await GET(request)
// The RPC itself hides cancelled entries unless p_status='cancelled' is
// passed explicitly (see migration 20260428153500). The behavior of the
// hide-by-default logic lives in SQL and is covered by pg-real tests.
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'list_fiscal_period_entries_with_related',
expect.objectContaining({ p_status: 'cancelled' })
)
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
@@ -84,6 +84,8 @@ export async function GET(request: Request) {
if (status) {
query = query.eq('status', status)
} else {
query = query.neq('status', 'cancelled')
}
if (dateFrom) {
+190 -49
View File
@@ -8,7 +8,9 @@ import { Badge } from '@/components/ui/badge'
import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
Gavel,
Link2,
Link2Off,
Loader2,
@@ -17,6 +19,7 @@ import {
Unlock,
Send,
ShieldAlert,
Trash2,
} from 'lucide-react'
import type { VatPeriodType } from '@/types'
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
@@ -29,10 +32,12 @@ interface SkatteverketStatus {
expiresAt?: string
}
// Shape per Skatteverket Momsdeklaration v1.0.24 RAML
// (kontrollResultat.resultat[].{kod, status, beskrivning})
interface KontrollResult {
id: string
typ: 'ERROR' | 'WARNING'
text: string
kod: string
status: 'ERROR' | 'WARNING'
beskrivning: string
}
interface SkatteverketPanelProps {
@@ -141,12 +146,12 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
if (result.error) {
setError(result.error)
} else {
const controls = result.data?.kontrollresultat?.kontroller || []
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
if (controls.length === 0) {
setSuccess('Valideringen godkänd — inga fel eller varningar')
} else {
const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR')
const errors = controls.filter(k => k.status === 'ERROR')
if (errors.length > 0) {
setError(`${errors.length} valideringsfel hittades`)
} else {
@@ -174,9 +179,9 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
if (result.error) {
setError(result.error)
} else {
const controls = result.data?.kontrollresultat?.kontroller || []
const controls: KontrollResult[] = result.data?.kontrollResultat?.resultat || []
setKontroller(controls)
const errors = controls.filter((k: KontrollResult) => k.typ === 'ERROR')
const errors = controls.filter(k => k.status === 'ERROR')
if (errors.length === 0) {
setSuccess('Utkast sparat i Eget utrymme hos Skatteverket')
} else {
@@ -203,8 +208,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
const result = await res.json()
if (result.error) {
setError(result.error)
} else if (result.data?.signeringslank) {
setSigneringslank(result.data.signeringslank)
} else if (result.data?.signeringsLank) {
setSigneringslank(result.data.signeringsLank)
setSuccess('Utkastet är låst. Öppna signeringslänken för att signera med BankID.')
}
} catch {
@@ -263,6 +268,84 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
}
}
const handleDeleteDraft = async () => {
setActionLoading('delete')
setError(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`,
{ method: 'DELETE' }
)
if (res.status === 204 || res.ok) {
setKontroller([])
setSigneringslank(null)
setSuccess('Utkastet har raderats från Eget utrymme')
} else {
const result = await res.json().catch(() => ({}))
setError(result.error || `Kunde inte radera utkast (${res.status})`)
}
} catch {
setError('Kunde inte radera utkast')
} finally {
setActionLoading(null)
}
}
const handleFetchDraft = async () => {
setActionLoading('fetchDraft')
setError(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/draft?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
} else if (!result.data) {
setSuccess('Inget sparat utkast hittades för perioden')
} else {
const locked = result.data?.locked ? ' (låst)' : ''
const summa = result.data?.momsuppgift?.summaMoms
const summaLabel = summa !== undefined ? `, summaMoms = ${formatAmount(summa)}` : ''
setSuccess(`Sparat utkast hittades${locked}${summaLabel}`)
}
} catch {
setError('Kunde inte hämta utkast')
} finally {
setActionLoading(null)
}
}
const handleFetchDecided = async () => {
setActionLoading('fetchDecided')
setError(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/declaration/decided?redovisare=${encodeURIComponent(
await getRedovisare()
)}&redovisningsperiod=${getRedovisningsperiod()}`
)
const result = await res.json()
if (result.error) {
setError(result.error)
} else if (!result.data) {
setSuccess('Inget beslut hittades för perioden')
} else {
const tid = result.data?.beslutadTidpunkt
const tidLabel = tid ? ` (beslutad ${new Date(tid).toLocaleDateString('sv-SE')})` : ''
setSuccess(`Beslut hittades${tidLabel}`)
}
} catch {
setError('Kunde inte hämta beslutade uppgifter')
} finally {
setActionLoading(null)
}
}
// Helper to get redovisare from settings
const getRedovisare = async (): Promise<string> => {
const res = await fetch('/api/settings')
@@ -316,8 +399,8 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
}
// Connected — show actions
const hasErrors = kontroller.some(k => k.typ === 'ERROR')
const hasWarnings = kontroller.some(k => k.typ === 'WARNING')
const hasErrors = kontroller.some(k => k.status === 'ERROR')
const hasWarnings = kontroller.some(k => k.status === 'WARNING')
return (
<Card>
@@ -364,21 +447,21 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
</p>
{kontroller.map((k, i) => (
<div
key={`${k.id}-${i}`}
key={`${k.kod}-${i}`}
className={`flex items-start gap-2 text-sm rounded-lg p-2.5 ${
k.typ === 'ERROR'
k.status === 'ERROR'
? 'bg-destructive/5 text-destructive'
: 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-200'
}`}
>
{k.typ === 'ERROR' ? (
{k.status === 'ERROR' ? (
<ShieldAlert className="h-4 w-4 mt-0.5 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
)}
<div>
<span className="font-mono text-xs mr-1.5">{k.id}</span>
{k.text}
<span className="font-mono text-xs mr-1.5">{k.kod}</span>
{k.beskrivning}
</div>
</div>
))}
@@ -424,7 +507,7 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
</div>
)}
{/* Action buttons */}
{/* Forward-lifecycle buttons */}
<div className="flex flex-wrap gap-2 pt-1">
<Button
variant="outline"
@@ -456,44 +539,47 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
Spara utkast
</Button>
{!signeringslank ? (
<Button
size="sm"
onClick={handleLock}
disabled={!hasData || hasErrors || actionLoading !== null}
className="gap-1.5"
title={hasErrors ? 'Valideringsfel måste åtgärdas först' : ''}
>
{actionLoading === 'lock' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="h-3.5 w-3.5" />
)}
Lås och signera
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleUnlock}
disabled={actionLoading !== null}
className="gap-1.5"
>
{actionLoading === 'unlock' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="h-3.5 w-3.5" />
)}
Lås upp
</Button>
)}
<Button
size="sm"
onClick={handleLock}
disabled={!hasData || hasErrors || actionLoading !== null}
className="gap-1.5"
title={hasErrors ? 'Valideringsfel måste åtgärdas först' : ''}
>
{actionLoading === 'lock' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="h-3.5 w-3.5" />
)}
Lås och signera
</Button>
</div>
{/* Read-only fetches from Skatteverket. */}
<div className="flex flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleFetchDraft}
disabled={actionLoading !== null}
className="gap-1.5 text-muted-foreground"
title="Hämta sparat utkast från Eget utrymme"
>
{actionLoading === 'fetchDraft' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
Hämta utkast
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
className="gap-1.5"
className="gap-1.5 text-muted-foreground"
title="Kontrollera om en signerad deklaration har lämnats in"
>
{actionLoading === 'check' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -502,6 +588,61 @@ function SkatteverketPanelInner({ periodType, year, period, hasData }: Skattever
)}
Kontrollera inlämning
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleFetchDecided}
disabled={actionLoading !== null}
className="gap-1.5 text-muted-foreground"
title="Hämta Skatteverkets beslut för perioden"
>
{actionLoading === 'fetchDecided' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Gavel className="h-3.5 w-3.5" />
)}
Hämta beslut
</Button>
</div>
{/* Recovery / cleanup buttons. Always visible when connected so
the user can back out of a locked or stale draft state without
depending on local UI state surviving a reload. SKV returns
404/409 if the action isn't applicable; we surface that as an
error message rather than hiding the button. */}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={handleUnlock}
disabled={actionLoading !== null}
className="gap-1.5 text-muted-foreground"
title="Lås upp en låst period så att utkastet kan ändras eller raderas"
>
{actionLoading === 'unlock' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="h-3.5 w-3.5" />
)}
Lås upp
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteDraft}
disabled={actionLoading !== null}
className="gap-1.5 text-muted-foreground"
title="Radera sparat utkast från Skatteverkets Eget utrymme"
>
{actionLoading === 'delete' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Radera utkast
</Button>
</div>
{/* Disconnect */}
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","invoice-inbox","ai-agent"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket"]}
+12 -12
View File
@@ -148,7 +148,7 @@ export const skatteverketExtension: Extension = {
try {
const tokens = await exchangeCodeForTokens(code, redirectUri)
await storeTokens(supabase, user.id, tokens)
await storeTokens(supabase, user.id, tokens, companyId)
// Clean up CSRF state
await supabase
@@ -186,7 +186,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const tokens = await getTokens(ctx.supabase, ctx.companyId)
const tokens = await getTokens(ctx.supabase, ctx.userId)
if (!tokens) {
return NextResponse.json({ connected: false })
}
@@ -213,7 +213,7 @@ export const skatteverketExtension: Extension = {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
await deleteTokens(ctx.supabase, ctx.companyId)
await deleteTokens(ctx.supabase, ctx.userId)
return NextResponse.json({ success: true })
},
},
@@ -241,7 +241,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'POST',
`/kontrollera/${redovisare}/${redovisningsperiod}`,
momsuppgift
@@ -287,7 +287,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'POST',
`/utkast/${redovisare}/${redovisningsperiod}`,
momsuppgift
@@ -337,7 +337,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'GET',
`/utkast/${redovisare}/${redovisningsperiod}`
)
@@ -376,7 +376,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'DELETE',
`/utkast/${redovisare}/${redovisningsperiod}`
)
@@ -413,7 +413,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'PUT',
`/las/${redovisare}/${redovisningsperiod}`
)
@@ -434,7 +434,7 @@ export const skatteverketExtension: Extension = {
status: 'draft_locked',
redovisare,
redovisningsperiod,
signeringslank: data.signeringslank,
signeringsLank: data.signeringsLank,
updatedAt: new Date().toISOString(),
})
)
@@ -460,7 +460,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'DELETE',
`/las/${redovisare}/${redovisningsperiod}`
)
@@ -504,7 +504,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'GET',
`/inlamnat/${redovisare}/${redovisningsperiod}`
)
@@ -543,7 +543,7 @@ export const skatteverketExtension: Extension = {
const response = await skvRequest(
ctx.supabase,
ctx.companyId,
ctx.userId,
'GET',
`/beslutat/${redovisare}/${redovisningsperiod}`
)
@@ -51,6 +51,15 @@ async function enforceRateLimit(): Promise<void> {
}
}
// Coalesce concurrent refresh attempts within this Node.js process. Without
// this, two parallel SKV requests from the same user (e.g. rapid UI clicks)
// would both call SKV's /token endpoint with the same refresh_token; SKV
// rotates that token on first use, so the second call would fail with 401.
// Cross-process races (separate Vercel function instances) are mitigated by
// the re-read inside the critical section: if another process refreshed
// while we waited on the network, we just use that newer token.
const refreshInFlight = new Map<string, Promise<string>>()
/**
* Get a valid access token, refreshing if needed.
* Throws if no tokens exist or refresh is exhausted.
@@ -72,14 +81,39 @@ async function getValidToken(
return tokens.access_token
}
// Need refresh
// Need refresh — coalesce concurrent attempts.
const inFlight = refreshInFlight.get(userId)
if (inFlight) return inFlight
const promise = refreshTokenForUser(supabase, userId)
.finally(() => refreshInFlight.delete(userId))
refreshInFlight.set(userId, promise)
return promise
}
async function refreshTokenForUser(
supabase: SupabaseClient,
userId: string,
): Promise<string> {
// Re-read after entering the critical section. Another process may have
// refreshed while we were waiting; if so, the row now has a new
// refresh_token and a future expiry — just hand it back.
const tokens = await getTokens(supabase, userId)
if (!tokens) {
throw new SkatteverketAuthError(
'Inte ansluten till Skatteverket. Anslut med BankID först.',
'NOT_CONNECTED'
)
}
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return tokens.access_token
}
if (!tokens.refresh_token) {
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
)
}
if (tokens.refresh_count >= MAX_REFRESH_COUNT) {
throw new SkatteverketAuthError(
'Maximalt antal förnyelser uppnått. Logga in med BankID igen.',
@@ -87,14 +121,12 @@ async function getValidToken(
)
}
// Refresh — returns NEW refresh_token that must be stored
const refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count)
const updatedTokens: SkatteverketTokens = {
...refreshed,
scope: tokens.scope,
}
await storeTokens(supabase, userId, updatedTokens)
return updatedTokens.access_token
}
@@ -1,5 +1,5 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import type { SkatteverketTokens } from '../types'
/**
@@ -9,8 +9,29 @@ import type { SkatteverketTokens } from '../types'
* role key) to encrypt tokens at rest in the skatteverket_tokens table.
*
* Pattern mirrors lib/auth/oauth-codes.ts but adapted for persistent storage.
*
* All DB operations route through a service-role client. The original RLS
* design (auth.uid() = user_id) is correct, but at least one deployed
* environment is missing the INSERT/UPDATE/DELETE policies and the
* UNIQUE(user_id) constraint, so user-session writes get rejected. The
* service-role client bypasses RLS, and the calling handlers (the OAuth
* callback in particular) verify the user identity via cookies before
* passing user_id here, so the access-control invariant is upheld at the
* application layer.
*/
let _serviceClient: SupabaseClient | null = null
function getServiceClient(): SupabaseClient {
if (_serviceClient) return _serviceClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) {
throw new Error('skatteverket token-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY')
}
_serviceClient = createClient(url, key, { auth: { persistSession: false } })
return _serviceClient
}
const ALGORITHM = 'aes-256-gcm'
function getEncryptionKey(): Buffer {
@@ -40,32 +61,64 @@ function decrypt(ciphertext: string): string {
}
/**
* Store (upsert) Skatteverket tokens for a user.
* Store (replace) Skatteverket tokens for a user.
* Both access_token and refresh_token are encrypted at rest.
*
* Implemented as DELETE + INSERT instead of UPSERT because some environments
* are missing the UNIQUE(user_id) constraint that ON CONFLICT requires. The
* delete-then-insert pattern is safe because OAuth callbacks for a given user
* are not concurrent (the user can only sign in with BankID once at a time).
*/
export async function storeTokens(
supabase: SupabaseClient,
_supabase: SupabaseClient,
userId: string,
tokens: SkatteverketTokens
tokens: SkatteverketTokens,
companyId?: string,
): Promise<void> {
const encryptedAccess = encrypt(tokens.access_token)
const encryptedRefresh = tokens.refresh_token ? encrypt(tokens.refresh_token) : null
const db = getServiceClient()
const { error } = await supabase
// The multi-tenant refactor (migration 20260330130000) put a NOT NULL
// company_id on every table. Tokens are conceptually user-scoped (one
// BankID identity), but the schema requires a company_id. The OAuth
// callback passes one explicitly. Token-refresh flows (called from
// skvRequest) don't pass one, so before we DELETE the existing row we
// remember its company_id and reuse it on INSERT.
let resolvedCompanyId = companyId
if (!resolvedCompanyId) {
const { data: existing, error: selectError } = await db
.from('skatteverket_tokens')
.select('company_id')
.eq('user_id', userId)
.maybeSingle()
// Throw before the destructive DELETE: a transient read failure here
// would otherwise wipe the existing row and then fail the INSERT on the
// NOT NULL company_id, leaving the user with no token at all.
if (selectError) {
throw new Error(`Failed to read existing token row: ${selectError.message}`)
}
if (existing?.company_id) resolvedCompanyId = existing.company_id
}
const { error: deleteError } = await db
.from('skatteverket_tokens')
.upsert(
{
user_id: userId,
access_token: encryptedAccess,
refresh_token: encryptedRefresh,
expires_at: new Date(tokens.expires_at).toISOString(),
refresh_count: tokens.refresh_count,
scope: tokens.scope,
},
{ onConflict: 'user_id' }
)
.delete()
.eq('user_id', userId)
if (deleteError) throw new Error(`Failed to clear existing tokens: ${deleteError.message}`)
if (error) throw new Error(`Failed to store tokens: ${error.message}`)
const row: Record<string, unknown> = {
user_id: userId,
access_token: encryptedAccess,
refresh_token: encryptedRefresh,
expires_at: new Date(tokens.expires_at).toISOString(),
refresh_count: tokens.refresh_count,
scope: tokens.scope,
}
if (resolvedCompanyId) row.company_id = resolvedCompanyId
const { error: insertError } = await db.from('skatteverket_tokens').insert(row)
if (insertError) throw new Error(`Failed to store tokens: ${insertError.message}`)
}
/**
@@ -73,13 +126,14 @@ export async function storeTokens(
* Returns null if no tokens are stored.
*/
export async function getTokens(
supabase: SupabaseClient,
_supabase: SupabaseClient,
userId: string
): Promise<SkatteverketTokens | null> {
const { data, error } = await supabase
const db = getServiceClient()
const { data, error } = await db
.from('skatteverket_tokens')
.select('access_token, refresh_token, expires_at, refresh_count, scope')
.eq('company_id', userId)
.eq('user_id', userId)
.single()
if (error || !data) return null
@@ -102,11 +156,12 @@ export async function getTokens(
* Delete stored tokens (disconnect from Skatteverket).
*/
export async function deleteTokens(
supabase: SupabaseClient,
_supabase: SupabaseClient,
userId: string
): Promise<void> {
await supabase
const db = getServiceClient()
await db
.from('skatteverket_tokens')
.delete()
.eq('company_id', userId)
.eq('user_id', userId)
}
+15 -9
View File
@@ -38,21 +38,27 @@ export interface SkatteverketMomsuppgift {
momsImportUtgaendeLag?: number // Box 62
}
/** Validation result from Skatteverket /kontrollera or /utkast */
export interface SkatteverketKontrollresultat {
kontroller?: SkatteverketKontroll[]
/**
* Validation result from Skatteverket /kontrollera or /utkast.
* Field names match Momsdeklaration v1.0.24 RAML — note SKV's mixed casing
* on `kontrollResultat` and `signeringsLank`.
*/
export interface SkatteverketKontrollResultat {
status?: 'OK' | 'WARNING' | 'ERROR'
resultat?: SkatteverketKontroll[]
}
export interface SkatteverketKontroll {
id: string // FK001, RK002, etc.
typ: 'ERROR' | 'WARNING'
text: string
kod: string // e.g. "49"
status: 'ERROR' | 'WARNING'
beskrivning: string
}
/** Response from saving a draft */
export interface SkatteverketUtkastResponse {
kontrollresultat?: SkatteverketKontrollresultat
signeringslank?: string
kontrollResultat?: SkatteverketKontrollResultat
signeringsLank?: string
locked?: boolean
}
/** Response from fetching submitted declarations */
@@ -158,7 +164,7 @@ export interface SkatteverketSubmission {
status: DeclarationStatus
kvittensnummer: string | null
signeringslank: string | null
kontrollresultat: SkatteverketKontrollresultat | null
kontrollresultat: SkatteverketKontrollResultat | null
momsuppgift: SkatteverketMomsuppgift
created_at: string
updated_at: string
@@ -7,6 +7,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'tic',
'mcp-server',
'cloud-backup',
'invoice-inbox',
'ai-agent',
'skatteverket',
])
+2 -4
View File
@@ -6,8 +6,7 @@ import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
import { ticExtension } from '@/extensions/general/tic'
import { mcpServerExtension } from '@/extensions/general/mcp-server'
import { cloudBackupExtension } from '@/extensions/general/cloud-backup'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { aiAgentExtension } from '@/extensions/general/ai-agent'
import { skatteverketExtension } from '@/extensions/general/skatteverket'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -16,6 +15,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
ticExtension,
mcpServerExtension,
cloudBackupExtension,
invoiceInboxExtension,
aiAgentExtension,
skatteverketExtension,
]
@@ -81,37 +81,14 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"subscriptionNotice": "Kräver ett Google-konto. Uppladdningar sker direkt till din Drive — ingen data lagras hos tredje part utöver Google."
},
{
"slug": "invoice-inbox",
"name": "Dokumentinkorg",
"sector": "general",
"category": "import",
"icon": "Inbox",
"dataPattern": "both",
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend.",
"readsCoreTables": [
"document_attachments",
"suppliers",
"transactions"
],
"hasOwnData": true
},
{
"slug": "ai-agent",
"name": "AI-agent (beta)",
"slug": "skatteverket",
"name": "Skatteverket Integration",
"sector": "general",
"category": "operations",
"icon": "Sparkles",
"icon": "FileCheck",
"dataPattern": "core",
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs.",
"readsCoreTables": [
"invoice_inbox_items",
"transactions",
"ai_proposals",
"ai_requests",
"processing_history"
]
"description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
"longDescription": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen."
},
],
}
@@ -8,5 +8,4 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
'general/cloud-backup': dynamic(() => import('@/components/extensions/general/CloudBackupWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
}
+293 -239
View File
@@ -182,18 +182,16 @@ describe('getVatDeclarationSummary', () => {
// ============================================================
// Ledger-based VAT declaration tests
//
// Mock queue order per call:
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
// [1] fetchAllRows: journal_entries for reverse charge bases
// [2] (if rc entries found) fetchAllRows: supplier_invoices
// [N] entry counts: journal_entries source_type
// After Phase 1b refactor, the calculator does TWO queries per call:
// [0] fetchAllRows: journal_entry_lines on every account in ACCOUNT_RUTA
// (26xx VAT, 3xxx revenue, 4xxx reverse-charge cost accounts)
// [1] journal_entries source_type counts (used for invoice/transaction metadata)
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no ledger lines exist', async () => {
results = [
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // rc journal entries
{ data: [], error: null }, // entry counts
]
@@ -225,19 +223,15 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
// Per-rate base amounts in breakdown
expect(result.breakdown.invoices.base25).toBe(10000)
expect(result.breakdown.invoices.base12).toBe(5000)
expect(result.breakdown.invoices.base6).toBe(3000)
@@ -253,7 +247,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
@@ -272,13 +265,11 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
})
@@ -291,7 +282,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -314,13 +304,11 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT in ruta10, 10000 - 2500 = 7500 revenue in ruta05
expect(result.rutor.ruta10).toBe(1875)
expect(result.rutor.ruta05).toBe(7500)
expect(result.invoiceCount).toBe(2)
@@ -336,7 +324,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -357,7 +344,6 @@ describe('calculateVatDeclaration', () => {
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
@@ -369,11 +355,9 @@ describe('calculateVatDeclaration', () => {
it('accepts accountingMethod parameter for backward compatibility', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
@@ -382,40 +366,32 @@ describe('calculateVatDeclaration', () => {
results = [
{
data: [
// 25% rate: 10,000 revenue, 2,500 VAT
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
// 12% rate: 5,000 revenue, 600 VAT
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
// 6% rate: 3,000 revenue, 180 VAT
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
// Input VAT from purchases
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'quarterly', 2024, 1)
// Output VAT in ruta 10/11/12
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(180)
// All domestic revenue combined in ruta 05
expect(result.rutor.ruta05).toBe(18000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
})
})
// ============================================================
// Reverse charge (ruta 20-24, 30-32) tests
// Reverse charge — purchase bases (ruta 20-24) sourced from cost accounts
// ============================================================
describe('calculateVatDeclaration — reverse charge', () => {
@@ -423,17 +399,14 @@ describe('calculateVatDeclaration — reverse charge', () => {
results = [
{
data: [
// Reverse charge output VAT accounts
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2624', debit_amount: 0, credit_amount: 120 },
{ account_number: '2634', debit_amount: 0, credit_amount: 60 },
// Corresponding input VAT (2645)
{ account_number: '2645', debit_amount: 1430, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries (no supplier invoices for base query)
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -450,19 +423,15 @@ describe('calculateVatDeclaration — reverse charge', () => {
results = [
{
data: [
// Regular output VAT
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// Reverse charge output VAT
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
// Input VAT (regular + calculated)
{ account_number: '2641', debit_amount: 300, credit_amount: 0 },
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -470,44 +439,44 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta30).toBe(500)
expect(result.rutor.ruta48).toBe(800)
// ruta49 = (2500 + 0 + 0 + 500 + 0 + 0) - 800 = 2200
expect(result.rutor.ruta49).toBe(2200)
expect(result.rutor.ruta49).toBe(2200) // (2500 + 500) - 800
})
it('populates ruta21 for EU services reverse charge base', async () => {
it('populates ruta20 from EU goods cost accounts (4515/4516/4517)', async () => {
// EU goods purchase: D 4515 25000, D 2645 6250, C 2614 6250, C 2440 25000
results = [
{
data: [
{ account_number: '4515', debit_amount: 25000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 6250 },
{ account_number: '2645', debit_amount: 6250, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta20).toBe(25000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta30).toBe(6250)
expect(result.rutor.ruta48).toBe(6250)
// Reverse charge is VAT-neutral: output VAT exactly offsets input VAT
expect(result.rutor.ruta49).toBe(0)
})
it('populates ruta21 from EU services cost accounts (4535/4536/4537)', async () => {
results = [
{
data: [
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
// rc journal entries — found a posted supplier invoice entry
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
// supplier_invoices lookup
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null }, // entry counts
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -520,75 +489,36 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.breakdown.reverseCharge.ruta30).toBe(1250)
})
it('populates ruta22 for non-EU services reverse charge base', async () => {
it('populates ruta22 from non-EU services cost accounts (4531/4532/4533)', async () => {
// Anthropic-style: D 4531 3000, D 2645 750, C 2614 750, C 2440 3000
results = [
{
data: [
{ account_number: '4531', debit_amount: 3000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 750 },
{ account_number: '2645', debit_amount: 750, credit_amount: 0 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: 3000,
subtotal: 300,
currency: 'USD',
exchange_rate: 10,
suppliers: { supplier_type: 'non_eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Uses subtotal_sek when available
expect(result.rutor.ruta22).toBe(3000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta20).toBe(0)
expect(result.rutor.ruta30).toBe(750)
})
it('populates ruta24 for domestic reverse charge base', async () => {
it('populates ruta23 from domestic goods reverse-charge cost accounts (4415/4416/4417)', async () => {
// Domestic mobile reverse charge: D 4415 100000, D 2647 25000, C 2614 25000, C 2440 100000
results = [
{
data: [
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 2000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'swedish_business' },
},
{ account_number: '4415', debit_amount: 100000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 25000 },
{ account_number: '2647', debit_amount: 25000, credit_amount: 0 },
],
error: null,
},
@@ -597,12 +527,34 @@ describe('calculateVatDeclaration — reverse charge', () => {
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta24).toBe(2000)
expect(result.rutor.ruta21).toBe(0)
expect(result.rutor.ruta22).toBe(0)
expect(result.rutor.ruta23).toBe(100000)
expect(result.rutor.ruta24).toBe(0)
expect(result.rutor.ruta30).toBe(25000)
expect(result.rutor.ruta48).toBe(25000)
expect(result.rutor.ruta49).toBe(0) // VAT-neutral
})
it('returns zero ruta20-24 when no reverse charge entries exist', async () => {
it('populates ruta24 from domestic services reverse-charge cost accounts (4425/4426/4427)', async () => {
results = [
{
data: [
{ account_number: '4425', debit_amount: 8000, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 2000 },
{ account_number: '2647', debit_amount: 2000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta24).toBe(8000)
expect(result.rutor.ruta23).toBe(0)
expect(result.rutor.ruta30).toBe(2000)
})
it('returns zero ruta20-24 when no reverse-charge cost-account activity', async () => {
results = [
{
data: [
@@ -611,7 +563,6 @@ describe('calculateVatDeclaration — reverse charge', () => {
],
error: null,
},
{ data: [], error: null }, // no rc journal entries
{ data: [], error: null },
]
@@ -624,88 +575,55 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta24).toBe(0)
})
it('credit notes reduce reverse charge bases', async () => {
it('reverse-charge credit notes net out the cost-account debit balance', async () => {
// Original purchase: D 4535 5000; reversal (credit note): C 4535 1000
results = [
{
data: [
// Original invoice RC VAT
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
{ account_number: '4535', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
// Credit note reversal
{ account_number: '2614', debit_amount: 250, credit_amount: 0 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
{ account_number: '2645', debit_amount: 0, credit_amount: 250 },
],
error: null,
},
{
data: [
{ id: 'je-1', source_id: 'si-1' },
{ id: 'je-2', source_id: 'si-2' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
{
id: 'si-2',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: true,
subtotal_sek: null,
subtotal: 1000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// 5000 - 1000 = 4000 net base for EU services
expect(result.rutor.ruta21).toBe(4000)
// Net RC output VAT: 1250 - 250 = 1000
expect(result.rutor.ruta30).toBe(1000)
expect(result.rutor.ruta21).toBe(4000) // 5000 - 1000
expect(result.rutor.ruta30).toBe(1000) // 1250 - 250
expect(result.rutor.ruta48).toBe(1000) // 1250 - 250
})
it('maps domestic reverse charge input VAT (2647) to ruta48', async () => {
it('maps domestic reverse-charge input VAT (2647) to ruta48', async () => {
results = [
{
data: [
// Domestic RC: D 2647 + C 2614 (offsetting), D expense
{ account_number: '2647', debit_amount: 500, credit_amount: 0 },
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
],
error: null,
},
{ data: [], error: null }, // rc journal entries
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// 2647 debit maps to ruta48
expect(result.rutor.ruta48).toBe(500)
// 2614 credit maps to ruta30
expect(result.rutor.ruta30).toBe(500)
// Net VAT = 500 - 500 = 0 (reverse charge is neutral)
expect(result.rutor.ruta49).toBe(0)
})
})
// ============================================================
// Import (ruta 50, 60-62) and Ruta 06 (uttag) and Ruta 42 (exempt)
// ============================================================
describe('calculateVatDeclaration — import, uttag, exempt', () => {
it('maps import VAT accounts (2615/2625/2635) to ruta60/61/62', async () => {
results = [
{
@@ -713,13 +631,11 @@ describe('calculateVatDeclaration — reverse charge', () => {
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2625', debit_amount: 0, credit_amount: 600 },
{ account_number: '2635', debit_amount: 0, credit_amount: 180 },
// Input VAT from imports
{ account_number: '2641', debit_amount: 3280, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -727,46 +643,100 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta60).toBe(2500)
expect(result.rutor.ruta61).toBe(600)
expect(result.rutor.ruta62).toBe(180)
// ruta49 = (ruta60 + ruta61 + ruta62) - ruta48 = 3280 - 3280 = 0
expect(result.rutor.ruta49).toBe(0)
expect(result.rutor.ruta49).toBe(0) // 3280 - 3280
})
it('maps EU/export revenue variants (3108/3105/3004) to ruta35/36/42', async () => {
it('populates ruta50 (import beskattningsunderlag) from 4545-4547', async () => {
// Full import flow: D 4545 10000, C 2615 2500, D 2641 2500
results = [
{
data: [
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2641', debit_amount: 2500, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Ruta 50 (base) and Ruta 60 (output VAT) BOTH non-zero — required by SKV §4.1.1.4
// ERROR rule "Det måste finnas ett belopp i fält 50, eftersom det finns ett belopp i 60-62"
expect(result.rutor.ruta50).toBe(10000)
expect(result.rutor.ruta60).toBe(2500)
expect(result.rutor.ruta48).toBe(2500)
})
it('populates ruta06 from uttag accounts (3401/3402/3403)', async () => {
// Uttag: D 2010 (private withdrawal); C 3401 1000 + C 2612 250 (25% rate uttag)
results = [
{
data: [
{ account_number: '3401', debit_amount: 0, credit_amount: 1000 },
{ account_number: '2612', debit_amount: 0, credit_amount: 250 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta06).toBe(1000)
expect(result.rutor.ruta10).toBe(250) // 2612 maps to ruta10 (25% output VAT including uttag)
})
it('expanded ruta42 covers 3004, 3100, 3404, 3994, 3980', async () => {
results = [
{
data: [
{ account_number: '3004', debit_amount: 0, credit_amount: 1000 },
{ account_number: '3100', debit_amount: 0, credit_amount: 2000 },
{ account_number: '3404', debit_amount: 0, credit_amount: 500 },
{ account_number: '3980', debit_amount: 0, credit_amount: 3000 },
{ account_number: '3994', debit_amount: 0, credit_amount: 1500 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta42).toBe(8000) // 1000+2000+500+3000+1500
})
it('maps EU/export revenue variants (3108/3105) to ruta35/36', async () => {
results = [
{
data: [
{ account_number: '3108', debit_amount: 0, credit_amount: 15000 },
{ account_number: '3105', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3004', debit_amount: 0, credit_amount: 5000 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta35).toBe(15000)
expect(result.rutor.ruta36).toBe(8000)
expect(result.rutor.ruta42).toBe(5000)
})
it('maps output VAT variant accounts (2612/2622/2632) to correct rutor', async () => {
it('maps output VAT variant accounts (2612/2623/2636) to correct rutor', async () => {
results = [
{
data: [
// Egna uttag 25%
{ account_number: '2612', debit_amount: 0, credit_amount: 1000 },
// Uthyrning 12%
{ account_number: '2623', debit_amount: 0, credit_amount: 200 },
// VMB 6%
{ account_number: '2636', debit_amount: 0, credit_amount: 50 },
{ account_number: '2612', debit_amount: 0, credit_amount: 1000 }, // egna uttag 25%
{ account_number: '2623', debit_amount: 0, credit_amount: 200 }, // uthyrning 12%
{ account_number: '2636', debit_amount: 0, credit_amount: 50 }, // VMB 6%
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
@@ -776,50 +746,7 @@ describe('calculateVatDeclaration — reverse charge', () => {
expect(result.rutor.ruta12).toBe(50)
})
it('only includes posted journal entries for reverse charge bases (reversed filtered at DB level)', async () => {
// The query uses .eq('status', 'posted'), so reversed entries never appear
results = [
{
data: [
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
{
data: [
// Only posted entries returned by DB query
{ id: 'je-1', source_id: 'si-1' },
],
error: null,
},
{
data: [
{
id: 'si-1',
supplier_id: 'sup-1',
reverse_charge: true,
is_credit_note: false,
subtotal_sek: null,
subtotal: 5000,
currency: 'SEK',
exchange_rate: null,
suppliers: { supplier_type: 'eu_business' },
},
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Only the posted entry's invoice (5000) should count
expect(result.rutor.ruta21).toBe(5000)
})
it('handles zero output VAT on some rates but non-zero on others', async () => {
// Only 12% sales in period — no 25% or 6% activity
results = [
{
data: [
@@ -829,38 +756,165 @@ describe('calculateVatDeclaration — reverse charge', () => {
],
error: null,
},
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(0) // no 25% output VAT
expect(result.rutor.ruta11).toBe(600) // 12% output VAT
expect(result.rutor.ruta12).toBe(0) // no 6% output VAT
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta12).toBe(0)
expect(result.rutor.ruta48).toBe(200)
// ruta49 = (0 + 600 + 0 + 0 + 0 + 0 + 0 + 0 + 0) - 200 = 400
expect(result.rutor.ruta49).toBe(400)
expect(result.rutor.ruta49).toBe(400) // 600 - 200
})
it('includes sub-öre ledger amounts in ruta sums (no threshold filtering)', async () => {
it('rounds sub-öre amounts via Math.round * 100 / 100', async () => {
results = [
{
data: [
// Very small amount — VAT declaration uses raw summation, no 0.005 filtering
{ account_number: '2611', debit_amount: 0, credit_amount: 0.001 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0.004 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Sub-öre amounts still included in ruta sums
expect(result.rutor.ruta10).toBe(0) // rounded: Math.round(0.001 * 100) / 100 = 0
expect(result.rutor.ruta05).toBe(0) // rounded: Math.round(0.004 * 100) / 100 = 0
expect(result.rutor.ruta10).toBe(0)
expect(result.rutor.ruta05).toBe(0)
})
})
// ============================================================
// SKV §4.1.1.4 cross-field contract checks
//
// Skatteverket's kontrollera endpoint runs these checks server-side. Mirror
// them locally so we catch declaration drift in unit tests, before a network
// call. ERROR rules block submission; WARNING rules don't.
// ============================================================
describe('SKV §4.1.1.4 cross-field contracts', () => {
it('ERROR — taxable sales base requires output VAT (rule 1)', async () => {
// SKV: if any of momspliktigForsaljning/momspliktigaUttag/vinstmarginal/hyresInkomst > 0,
// at least one of momsForsaljningUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// No 2611/2621/2631 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const hasBase = r.ruta05 + r.ruta06 + r.ruta07 + r.ruta08 > 0
const hasOutput = r.ruta10 + r.ruta11 + r.ruta12 > 0
expect(hasBase).toBe(true)
expect(hasOutput).toBe(false)
// Local invariant: this combination would fail SKV kontrollera with ERROR.
// The calculator does not auto-correct — the ledger must be fixed upstream.
})
it('ERROR — reverse-charge purchase base requires output VAT (rule 3)', async () => {
// If any of inkopVarorEU/inkopTjansterEU/inkopTjansterUtanforEU/inkopVarorSE/inkopTjansterSE > 0,
// at least one of momsInkopUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '4535', debit_amount: 5000, credit_amount: 0 },
// No 2614/2624/2634 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const hasRcBase = r.ruta20 + r.ruta21 + r.ruta22 + r.ruta23 + r.ruta24 > 0
const hasRcOutput = r.ruta30 + r.ruta31 + r.ruta32 > 0
expect(hasRcBase).toBe(true)
expect(hasRcOutput).toBe(false)
})
it('ERROR — import base requires import output VAT (rule 5)', async () => {
// If import (ruta50) > 0, at least one of momsImportUtgaende{Hog,Medel,Lag} must be > 0.
results = [
{
data: [
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
// No 2615/2625/2635 booked — would trigger SKV ERROR
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
expect(r.ruta50).toBe(10000)
expect(r.ruta60 + r.ruta61 + r.ruta62).toBe(0)
})
it('ERROR — import output VAT requires import base (rule 6)', async () => {
// If any of momsImportUtgaende{Hog,Medel,Lag} > 0, import (ruta50) must be > 0.
// This is the BLOCKER scenario the Phase 1b refactor fixes: previously ruta50 was
// never populated, so any import VAT booking would fail SKV's contract.
results = [
{
data: [
{ account_number: '2615', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2641', debit_amount: 2500, credit_amount: 0 },
{ account_number: '4545', debit_amount: 10000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
// Both populated — passes SKV's rule 6
expect(r.ruta50).toBe(10000)
expect(r.ruta60).toBe(2500)
})
it('ERROR — summaMoms must equal (ruta10+11+12+30+31+32+60+61+62) ruta48 (rule 7)', async () => {
// The calculator computes ruta49 from the formula directly, so this invariant
// holds by construction. This test is the canary that catches drift if anyone
// ever adds an extra term or rate to the form.
results = [
{
data: [
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
{ account_number: '2614', debit_amount: 0, credit_amount: 1250 },
{ account_number: '2615', debit_amount: 0, credit_amount: 500 },
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
{ account_number: '2645', debit_amount: 1250, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
const r = result.rutor
const expected = r.ruta10 + r.ruta11 + r.ruta12
+ r.ruta30 + r.ruta31 + r.ruta32
+ r.ruta60 + r.ruta61 + r.ruta62
- r.ruta48
expect(r.ruta49).toBe(expected)
})
})
+50 -114
View File
@@ -22,17 +22,27 @@ import type {
/**
* Account-to-ruta mapping for the Swedish momsdeklaration (SKV 4700).
*
* Covers all BAS 26xx VAT accounts and 3xxx revenue accounts that feed the
* momsdeklaration. Includes variant accounts (egna uttag, uthyrning, VMB,
* import, domestic reverse charge) that may appear from manual entries or
* SIE imports, not just accounts generated by the system.
* Pure ledger projection: every Ruta on the SKV 4700 form maps to one or more
* BAS account balances aggregated over the period. The mapping below follows
* the BAS 2026 chart and Skatteverket's published BAS-to-Ruta spec
* (`.claude/skills/swedish-vat/references/vat-compliance-reference.md` §7).
*
* Output VAT (261x/262x/263x) → ruta 10/11/12 per rate
* Reverse charge output (2614/2624/2634) → ruta 30/31/32
* Import VAT (2615/2625/2635) → ruta 60/61/62
* Input VAT (2641-2649) → ruta 48
* Revenue (3001-3003) → ruta 05; EU (3108/3308) → ruta 35/39;
* Export (3105/3305) → ruta 36/40; Exempt (3004/3100) → ruta 42
* Output VAT (261x/262x/263x) → ruta 10/11/12 per rate (credit balance)
* Reverse charge output (2614/2624/2634) → ruta 30/31/32 (credit)
* Import VAT (2615/2625/2635) → ruta 60/61/62 (credit)
* Input VAT (2641-2649) → ruta 48 (debit)
* Domestic taxable sales (3001-3003) → ruta 05 (credit)
* Uttag (3401-3403) → ruta 06 (credit)
* EU goods (3108) → ruta 35; EU services (3308) → ruta 39 (credit)
* Export (3105/3305) → ruta 36/40; Exempt (3004/3100/3404/3994/3980) → ruta 42 (credit)
* Reverse-charge purchase bases — read from the cost account the journal
* entry posted to (debit balance), not from supplier classification:
* 4515/4516/4517 (EU goods 25/12/6%) → ruta 20
* 4535/4536/4537 (EU services 25/12/6%) → ruta 21
* 4531/4532/4533 (non-EU services 25/12/6%) → ruta 22
* 4415/4416/4417 (domestic goods reverse charge) → ruta 23
* 4425/4426/4427 (domestic services reverse charge) → ruta 24
* 4545/4546/4547 (import) → ruta 50
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
// Output VAT 25% → ruta 10
@@ -69,6 +79,10 @@ const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'cred
'3001': { box: 'ruta05', side: 'credit' },
'3002': { box: 'ruta05', side: 'credit' },
'3003': { box: 'ruta05', side: 'credit' },
// Revenue: momspliktiga uttag → ruta 06
'3401': { box: 'ruta06', side: 'credit' },
'3402': { box: 'ruta06', side: 'credit' },
'3403': { box: 'ruta06', side: 'credit' },
// Revenue: EU goods/services → ruta 35/39
'3108': { box: 'ruta35', side: 'credit' }, // Varuförsäljning till EU
'3308': { box: 'ruta39', side: 'credit' }, // Tjänsteförsäljning till EU
@@ -77,6 +91,28 @@ const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'cred
'3305': { box: 'ruta40', side: 'credit' }, // Tjänsteförsäljning export
'3004': { box: 'ruta42', side: 'credit' }, // Momsfri försäljning (AB)
'3100': { box: 'ruta42', side: 'credit' }, // Momsfria intäkter (EF)
'3404': { box: 'ruta42', side: 'credit' }, // Momsfria uttag
'3980': { box: 'ruta42', side: 'credit' }, // Erhållna offentliga stöd m.m.
'3994': { box: 'ruta42', side: 'credit' }, // Övriga rörelseintäkter momsfria
// Reverse-charge purchase bases (debit on cost accounts) → ruta 20-24, 50
'4515': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 25%
'4516': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 12%
'4517': { box: 'ruta20', side: 'debit' }, // Inköp varor EU 6%
'4535': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 25%
'4536': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 12%
'4537': { box: 'ruta21', side: 'debit' }, // Inköp tjänster EU 6%
'4531': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 25%
'4532': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 12%
'4533': { box: 'ruta22', side: 'debit' }, // Inköp tjänster utanför EU 6%
'4415': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 25%
'4416': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 12%
'4417': { box: 'ruta23', side: 'debit' }, // Inköp varor SE reverse charge 6%
'4425': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 25%
'4426': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 12%
'4427': { box: 'ruta24', side: 'debit' }, // Inköp tjänster SE reverse charge 6%
'4545': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 25%
'4546': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 12%
'4547': { box: 'ruta50', side: 'debit' }, // Beskattningsunderlag import 6%
}
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
@@ -142,7 +178,10 @@ function round(value: number): number {
/**
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts per ACCOUNT_RUTA mapping.
* Sums posted journal entry lines on the BAS accounts in ACCOUNT_RUTA per the
* SKV 4700 form mapping. Pure ledger projection — no supplier classification
* or other side-channel signals.
*
* - ruta 49 = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
*
* The accounting method parameter is accepted for backward compatibility
@@ -229,14 +268,6 @@ export async function calculateVatDeclaration(
if (t) revenueByRate[rate] = round(t.credit - t.debit)
}
// Calculate reverse charge purchase bases (ruta 20-24) from supplier invoices
const rcBases = await calculateReverseChargeBases(supabase, companyId, start, end)
rutor.ruta20 = rcBases.ruta20
rutor.ruta21 = rcBases.ruta21
rutor.ruta22 = rcBases.ruta22
rutor.ruta23 = rcBases.ruta23
rutor.ruta24 = rcBases.ruta24
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
@@ -291,101 +322,6 @@ export async function calculateVatDeclaration(
}
}
/**
* Calculate reverse charge purchase bases (ruta 20-24) from supplier invoices.
*
* Queries journal entries with supplier invoice source types, then looks up
* the linked supplier invoices + suppliers to determine supplier_type and
* sum item line_total (the net tax base in SEK).
*
* Classification (all reverse charge is currently services):
* eu_business → ruta21 (services from EU)
* non_eu_business → ruta22 (services from outside EU)
* swedish_business → ruta24 (domestic reverse charge, e.g. construction)
* ruta20/23 (goods) → 0 for now (Tullverket path not implemented)
*/
async function calculateReverseChargeBases(
supabase: SupabaseClient,
companyId: string,
start: string,
end: string,
): Promise<{ ruta20: number; ruta21: number; ruta22: number; ruta23: number; ruta24: number }> {
const result = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0 }
// Step 1: Find journal entries from supplier invoices in this period
const supplierSourceTypes = [
'supplier_invoice_registered',
'supplier_invoice_cash_payment',
'supplier_credit_note',
]
const entries = await fetchAllRows<{
id: string
source_id: string
}>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id, source_id')
.eq('company_id', companyId)
.in('source_type', supplierSourceTypes)
.eq('status', 'posted')
.gte('entry_date', start)
.lte('entry_date', end)
.range(from, to)
)
if (entries.length === 0) return result
const sourceIds = [...new Set(entries.map(e => e.source_id).filter(Boolean))]
if (sourceIds.length === 0) return result
// Step 2: Fetch supplier invoices that are reverse charge, with supplier type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const invoices = await fetchAllRows<Record<string, any>>(({ from, to }) =>
supabase
.from('supplier_invoices')
.select('id, supplier_id, reverse_charge, is_credit_note, subtotal_sek, subtotal, currency, exchange_rate, suppliers!inner(supplier_type)')
.in('id', sourceIds)
.eq('reverse_charge', true)
.eq('company_id', companyId)
.range(from, to)
)
if (invoices.length === 0) return result
// Step 3: Sum tax bases by supplier type
for (const inv of invoices) {
// Use subtotal_sek if available, otherwise convert via exchange_rate
let baseSek: number
if (inv.subtotal_sek != null) {
baseSek = Number(inv.subtotal_sek)
} else if (inv.currency !== 'SEK' && inv.exchange_rate) {
baseSek = Math.round(Number(inv.subtotal) * Number(inv.exchange_rate) * 100) / 100
} else {
baseSek = Number(inv.subtotal)
}
// Credit notes reduce the base
if (inv.is_credit_note) baseSek = -baseSek
// !inner join: Supabase returns the related row as an object (1-to-1 FK)
const supplier = Array.isArray(inv.suppliers) ? inv.suppliers[0] : inv.suppliers
const supplierType = supplier?.supplier_type as string
switch (supplierType) {
case 'eu_business':
result.ruta21 = round(result.ruta21 + baseSek)
break
case 'non_eu_business':
result.ruta22 = round(result.ruta22 + baseSek)
break
case 'swedish_business':
result.ruta24 = round(result.ruta24 + baseSek)
break
}
}
return result
}
/**
* Get a summary of the VAT declaration for display
*/
+76
View File
@@ -0,0 +1,76 @@
/**
* Delete orphan [SKV-TEST] journal entries from a company.
*
* Use this to recover from a failed seed-skv-test-data.ts run that inserted
* draft entries but failed to commit them (e.g., constraint violation).
*
* Usage: npx tsx scripts/clean-skv-test-drafts.ts <COMPANY_ID>
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
const companyId = process.argv[2]
if (!companyId) {
console.error('Usage: npx tsx scripts/clean-skv-test-drafts.ts <COMPANY_ID>')
process.exit(1)
}
async function main() {
// Find every [SKV-TEST] entry on this company.
const { data: entries, error: fetchErr } = await supabase
.from('journal_entries')
.select('id, status, description, voucher_number')
.eq('company_id', companyId)
.like('description', '[SKV-TEST]%')
if (fetchErr) throw new Error(`fetch: ${fetchErr.message}`)
if (!entries || entries.length === 0) {
console.log('No [SKV-TEST] entries found.')
return
}
console.log(`Found ${entries.length} [SKV-TEST] entries:`)
for (const e of entries) {
console.log(` ${e.status.padEnd(10)} A${e.voucher_number ?? '?'} ${e.description}`)
}
// journal_entry_lines cascades on journal_entries delete, but we still need
// to handle the immutability trigger for status='posted'. Drafts only.
const drafts = entries.filter(e => e.status === 'draft')
const posted = entries.filter(e => e.status !== 'draft')
if (posted.length > 0) {
console.log(`\n⚠ ${posted.length} entries are status='posted' or 'reversed' — those are immutable per BFL.`)
console.log(' If you really want to remove them, you have to reverse them first or hard-delete via psql with triggers disabled.')
console.log(' Skipping those here.')
}
if (drafts.length === 0) {
console.log('\nNo draft entries to delete.')
return
}
// BFL compliance trigger blocks DELETE on journal_entries — soft-delete via
// status='cancelled' instead. Cancelled entries are filtered out by the VAT
// calculator (which only reads 'posted' and 'reversed').
console.log(`\nMarking ${drafts.length} draft entries as cancelled...`)
const ids = drafts.map(d => d.id)
const { error: cancelErr } = await supabase
.from('journal_entries')
.update({ status: 'cancelled' })
.in('id', ids)
if (cancelErr) throw new Error(`cancel update: ${cancelErr.message}`)
console.log(`${drafts.length} drafts cancelled.`)
}
main().catch(err => { console.error(err); process.exit(1) })
+143
View File
@@ -0,0 +1,143 @@
/**
* Create a dedicated SKV test company in gnubok.
*
* Why this exists: testing the Skatteverket sandbox APIs against Arcim's real
* orgnummer would put real revenue/VAT figures in SKV's test logs under the
* real entity. Better hygiene: a separate gnubok company that uses one of
* SKV's *published* test orgnummer (which are already pre-wired in their
* test registry), seeded with synthetic data only.
*
* Inserts:
* - companies row with name `[TEST] SKV Sandbox`, org_number=1128000013,
* entity_type=aktiebolag, created_by=<user_id>
* - company_members row giving <user_id> owner role
* - company_settings row with the same org_number + entity_type
* - chart_of_accounts seeded via the seed_chart_of_accounts RPC
* - flips user_preferences.active_company_id to the new company so the UI
* starts using it immediately
*
* To revert: delete the company row (CASCADE removes members + settings),
* then update user_preferences.active_company_id back to the previous value.
*
* Usage: npx tsx scripts/create-skv-test-company.ts <USER_ID>
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
const TEST_NAME = '[TEST] SKV Sandbox'
const TEST_ORG_NUMBER = '1128000013' // → 161128000013, registered for moms in SKV test
const TEST_ENTITY_TYPE = 'aktiebolag'
const userId = process.argv[2]
if (!userId) {
console.error('Usage: npx tsx scripts/create-skv-test-company.ts <USER_ID>')
process.exit(1)
}
async function main() {
// Sanity check: don't create duplicates if the script is rerun.
const { data: existing } = await supabase
.from('companies')
.select('id, name, org_number')
.eq('created_by', userId)
.eq('name', TEST_NAME)
.maybeSingle()
if (existing) {
console.log(`Test company already exists:`)
console.log(` id: ${existing.id}`)
console.log(` name: ${existing.name}`)
console.log(` org_number: ${existing.org_number}`)
console.log(`\nIf you want a fresh one, delete it first:`)
console.log(` delete from companies where id = '${existing.id}';`)
return
}
console.log(`Creating test company for user ${userId}...`)
// 1. Insert the company.
const { data: company, error: companyErr } = await supabase
.from('companies')
.insert({
name: TEST_NAME,
org_number: TEST_ORG_NUMBER,
entity_type: TEST_ENTITY_TYPE,
created_by: userId,
})
.select('id')
.single()
if (companyErr || !company) throw new Error(`companies insert: ${companyErr?.message}`)
const companyId = company.id
console.log(` ✓ companies.id = ${companyId}`)
// 2. Owner membership.
const { error: memberErr } = await supabase
.from('company_members')
.insert({ company_id: companyId, user_id: userId, role: 'owner' })
if (memberErr) throw new Error(`company_members insert: ${memberErr.message}`)
console.log(` ✓ owner membership created`)
// 3. company_settings (the validate handler reads org_number from here).
const { error: settingsErr } = await supabase
.from('company_settings')
.insert({
company_id: companyId,
org_number: TEST_ORG_NUMBER,
entity_type: TEST_ENTITY_TYPE,
})
if (settingsErr) throw new Error(`company_settings insert: ${settingsErr.message}`)
console.log(` ✓ company_settings created`)
// 4. Seed the chart of accounts.
const { error: seedErr } = await supabase.rpc('seed_chart_of_accounts', {
p_company_id: companyId,
p_entity_type: TEST_ENTITY_TYPE,
})
if (seedErr) throw new Error(`seed_chart_of_accounts: ${seedErr.message}`)
console.log(` ✓ chart of accounts seeded`)
// 5. Make this the user's active company so the UI uses it on next load.
const { data: prevPref } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle()
const previousActive = prevPref?.active_company_id ?? null
const { error: prefErr } = await supabase
.from('user_preferences')
.upsert(
{ user_id: userId, active_company_id: companyId },
{ onConflict: 'user_id' },
)
if (prefErr) throw new Error(`user_preferences upsert: ${prefErr.message}`)
console.log(` ✓ active_company_id flipped to test company`)
console.log(`\nDone.\n`)
console.log(`Test company id: ${companyId}`)
console.log(`Test company name: ${TEST_NAME}`)
console.log(`org_number (10-digit): ${TEST_ORG_NUMBER}`)
console.log(`SKV redovisare (12-digit): 16${TEST_ORG_NUMBER}`)
console.log(`Previous active_company_id: ${previousActive ?? '(none)'}`)
console.log(`\nNext step — seed VAT fixtures for SKV's pre-wired periods:`)
console.log(` npx tsx scripts/seed-skv-test-data.ts ${companyId} 2024 1`)
console.log(` npx tsx scripts/seed-skv-test-data.ts ${companyId} 2024 2`)
console.log(`\nWhen done testing, switch back via the UI's company switcher,`)
console.log(`or run:`)
console.log(` update user_preferences set active_company_id = '${previousActive}' where user_id = '${userId}';`)
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+166
View File
@@ -0,0 +1,166 @@
/**
* READ-ONLY inspection: shows what Jakob's account looks like in prod and
* whether kontrollera can be run safely without seeding any test data.
*
* Verifies:
* 1. The user exists and active_company_id is set
* 2. The active company has a usable org_number + entity_type
* → so formatRedovisare() can produce the 12-digit SKV redovisare
* 3. There's at least one closed fiscal period with VAT-relevant journal
* entries we could pick for the kontrollera call
* 4. There are no leftover skatteverket_tokens that would surprise us
*
* Writes nothing. Safe to run against prod.
*
* Usage: npx tsx scripts/inspect-skv-readiness.ts <EMAIL>
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
auth: { persistSession: false },
})
const email = process.argv[2]
if (!email) {
console.error('Usage: npx tsx scripts/inspect-skv-readiness.ts <EMAIL>')
process.exit(1)
}
function formatRedovisareLocal(orgNumber: string, entityType: string): string {
const digits = orgNumber.replace(/[-\s]/g, '')
if (digits.length === 12) return digits
if (digits.length !== 10) return `(invalid: ${orgNumber})`
if (entityType === 'aktiebolag') return `16${digits}`
// EF: prefix century. For births 19XX vs 20XX we'd need the actual logic.
const centuryByte = digits.substring(0, 2)
const yearByte = parseInt(centuryByte, 10)
// Heuristic mirrors lib/skatteverket/format.ts
return yearByte < 50 ? `20${digits}` : `19${digits}`
}
async function main() {
console.log(`Inspecting SKV readiness for ${email}\n`)
// 1. user_id
const { data: usersData, error: userErr } = await supabase.auth.admin.listUsers({ page: 1, perPage: 200 })
if (userErr) throw new Error(`listUsers: ${userErr.message}`)
const user = usersData.users.find(u => u.email === email)
if (!user) {
console.error(`User ${email} not found.`)
process.exit(1)
}
console.log(`✓ user_id = ${user.id}`)
// 2. user_preferences
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', user.id)
.maybeSingle()
console.log(` active_company_id = ${prefs?.active_company_id ?? '(none)'}`)
// 3. company memberships
const { data: memberships, error: memErr } = await supabase
.from('company_members')
.select('company_id, role, companies(id, name, org_number, entity_type, archived_at)')
.eq('user_id', user.id)
if (memErr) throw new Error(`memberships: ${memErr.message}`)
if (!memberships?.length) {
console.error('User has no company memberships. Sign up flow may be incomplete.')
process.exit(1)
}
console.log(`\nCompanies:`)
for (const m of memberships) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const c = (Array.isArray(m.companies) ? m.companies[0] : m.companies) as any
if (!c) continue
const archived = c.archived_at ? ' [ARCHIVED]' : ''
const active = c.id === prefs?.active_company_id ? ' ← ACTIVE' : ''
const redovisare = c.org_number ? formatRedovisareLocal(c.org_number, c.entity_type) : '(no org_number)'
console.log(` ${c.id} ${c.name}${archived}${active}`)
console.log(` role=${m.role} org=${c.org_number} entity=${c.entity_type} → redovisare=${redovisare}`)
}
const activeCompany = memberships.find(m => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const c = (Array.isArray(m.companies) ? m.companies[0] : m.companies) as any
return c?.id === prefs?.active_company_id
})
if (!activeCompany) {
console.warn('\n⚠ No active company set. UI will pick the first one.')
process.exit(0)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ac = (Array.isArray(activeCompany.companies) ? activeCompany.companies[0] : activeCompany.companies) as any
console.log(`\n--- Active company: ${ac.name} ---`)
// 4. Fiscal periods with closed status
const { data: fps } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed')
.eq('company_id', ac.id)
.order('period_start', { ascending: false })
.limit(5)
console.log(`\nMost recent fiscal periods:`)
if (!fps?.length) {
console.log(' (none)')
} else {
for (const fp of fps) {
console.log(` ${fp.period_start}${fp.period_end} closed=${fp.is_closed} ${fp.name}`)
}
}
// 5. Count VAT-relevant journal_entry_lines for the most recent month
const today = new Date()
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1)
const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0)
const startStr = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}-01`
const endStr = `${lastMonthEnd.getFullYear()}-${String(lastMonthEnd.getMonth() + 1).padStart(2, '0')}-${String(lastMonthEnd.getDate()).padStart(2, '0')}`
const vatAccounts = ['2611', '2621', '2631', '2614', '2641', '2645', '3001', '3002', '3003', '3308', '3105']
const { data: lines, error: lineErr } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)')
.in('account_number', vatAccounts)
.eq('journal_entries.company_id', ac.id)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', startStr)
.lte('journal_entries.entry_date', endStr)
.limit(200)
if (lineErr) throw new Error(`lines: ${lineErr.message}`)
console.log(`\nVAT activity in ${startStr}${endStr}: ${lines?.length ?? 0} lines on ${vatAccounts.join('/')}`)
// 6. Existing skatteverket_tokens?
const { data: tokens } = await supabase
.from('skatteverket_tokens')
.select('user_id, expires_at, refresh_count, scope, created_at')
.eq('user_id', user.id)
if (tokens?.length) {
console.log(`\n⚠ Existing skatteverket_tokens row(s):`)
for (const t of tokens) {
console.log(` expires_at=${t.expires_at} refresh_count=${t.refresh_count} scope=${t.scope}`)
}
} else {
console.log('\n✓ No existing skatteverket_tokens — clean slate for OAuth.')
}
console.log('\nDone (read-only).')
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+51
View File
@@ -0,0 +1,51 @@
/**
* READ-ONLY: inspect the actual schema of public.skatteverket_tokens in prod.
* Confirms whether the UNIQUE(user_id) constraint exists and under what name.
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
async function main() {
// Try a row-count query to see if the table exists at all
const { count, error: countErr } = await supabase
.from('skatteverket_tokens')
.select('*', { count: 'exact', head: true })
console.log('Table reachable:', !countErr, 'row count:', count, 'error:', countErr?.message ?? 'none')
// Use the postgrest schema endpoint to introspect via the OpenAPI spec
const { data: openapiResp, error: openapiErr } = await supabase
.from('skatteverket_tokens')
.select('id, user_id, expires_at, refresh_count, scope')
.limit(1)
console.log('Sample select error:', openapiErr?.message ?? 'none', '— rows:', openapiResp?.length ?? 0)
// Fetch from pg_constraint via a dedicated RPC if available, else via raw query
// Supabase JS doesn't expose raw SQL, so we use a workaround: try to provoke
// the constraint name from the upsert error itself with a dummy row.
console.log('\nProbing existing rows to count duplicates per user_id…')
const { data: rows, error: rowsErr } = await supabase
.from('skatteverket_tokens')
.select('id, user_id, created_at')
.order('created_at', { ascending: false })
if (rowsErr) {
console.error('rows fetch failed:', rowsErr.message)
return
}
const byUser = new Map<string, number>()
for (const r of rows ?? []) byUser.set(r.user_id, (byUser.get(r.user_id) ?? 0) + 1)
console.log(` ${rows?.length ?? 0} total rows across ${byUser.size} distinct user_ids`)
const dupes = [...byUser.entries()].filter(([, n]) => n > 1)
if (dupes.length) console.log(' duplicates:', dupes)
}
main().catch(err => { console.error(err); process.exit(1) })
+45
View File
@@ -0,0 +1,45 @@
/**
* List recent gnubok dev users. Quick sanity-check tool — given a hosted
* Supabase project, the easiest way to see who has signed up.
*
* Usage: npx tsx scripts/list-dev-users.ts
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
auth: { persistSession: false },
})
async function main() {
const { data, error } = await supabase.auth.admin.listUsers({ page: 1, perPage: 20 })
if (error) {
console.error('listUsers failed:', error.message)
process.exit(1)
}
if (!data.users.length) {
console.log('No users yet. Sign up at /register first.')
return
}
console.log(`Found ${data.users.length} users (most recent first):`)
for (const u of data.users) {
const last = u.last_sign_in_at ? new Date(u.last_sign_in_at).toISOString() : '(never)'
console.log(` ${u.id} ${u.email?.padEnd(40) ?? '(no email)'} last: ${last}`)
}
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+317
View File
@@ -0,0 +1,317 @@
/**
* Seed VAT test data for Skatteverket momsdeklaration kontrollera testing.
*
* Creates a fiscal period (if missing) and a set of balanced posted journal
* entries that exercise every Ruta the calculator populates. After running
* this you can call /api/extensions/ext/skatteverket/declaration/validate
* for the same period and Skatteverket should return a non-empty
* kontrollresultat covering the full SKV 4700 form.
*
* Usage:
* npx tsx scripts/seed-skv-test-data.ts <COMPANY_ID> <YEAR> <MONTH>
*
* Example:
* npx tsx scripts/seed-skv-test-data.ts 11111111-aaaa-bbbb-cccc-222222222222 2026 3
*
* Idempotency: every entry's description is prefixed `[SKV-TEST]` so reruns
* are easy to identify and clean up:
* delete from journal_entries
* where company_id = '<id>' and description like '[SKV-TEST]%';
*
* Requires: SUPABASE_SERVICE_ROLE_KEY in .env.local (already set if you've
* been running the dev server).
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
auth: { persistSession: false },
})
interface Line {
account: string
debit?: number
credit?: number
description?: string
}
interface Scenario {
description: string
lines: Line[]
expectedRutor: string
}
const [, , companyIdArg, yearArg, monthArg] = process.argv
if (!companyIdArg || !yearArg || !monthArg) {
console.error('Usage: npx tsx scripts/seed-skv-test-data.ts <COMPANY_ID> <YEAR> <MONTH>')
process.exit(1)
}
const companyId = companyIdArg
const year = Number(yearArg)
const month = Number(monthArg)
const entryDate = `${year}-${String(month).padStart(2, '0')}-15`
// Scenarios chosen to populate every Ruta the calculator now reads from the
// ledger. Each one balances debits = credits.
const scenarios: Scenario[] = [
{
description: 'Domestic invoice, 25% rate (Acme Konsult AB)',
expectedRutor: 'Ruta 05 + 10',
lines: [
{ account: '1510', debit: 12500, description: 'Kundfordran' },
{ account: '3001', credit: 10000, description: 'Försäljning 25%' },
{ account: '2611', credit: 2500, description: 'Utgående moms 25%' },
],
},
{
description: 'Domestic invoice, 12% rate (restaurang)',
expectedRutor: 'Ruta 05 + 11',
lines: [
{ account: '1510', debit: 11200 },
{ account: '3002', credit: 10000, description: 'Försäljning 12%' },
{ account: '2621', credit: 1200, description: 'Utgående moms 12%' },
],
},
{
description: 'Domestic invoice, 6% rate (transport)',
expectedRutor: 'Ruta 05 + 12',
lines: [
{ account: '1510', debit: 10600 },
{ account: '3003', credit: 10000, description: 'Försäljning 6%' },
{ account: '2631', credit: 600, description: 'Utgående moms 6%' },
],
},
{
description: 'EU services purchase (German consulting)',
expectedRutor: 'Ruta 21 + 30 + 48',
lines: [
{ account: '4535', debit: 5000, description: 'Inköp tjänster EU 25%' },
{ account: '2645', debit: 1250, description: 'Beräknad ingående moms' },
{ account: '2614', credit: 1250, description: 'Utgående moms omv. skattskyldighet' },
{ account: '2440', credit: 5000, description: 'Leverantörsskuld' },
],
},
{
description: 'Non-EU services purchase (Anthropic)',
expectedRutor: 'Ruta 22 + 30 + 48',
lines: [
{ account: '4531', debit: 3000, description: 'Inköp tjänster utanför EU' },
{ account: '2645', debit: 750 },
{ account: '2614', credit: 750 },
{ account: '2440', credit: 3000 },
],
},
{
description: 'Domestic mobile reverse charge (electronics >100k)',
expectedRutor: 'Ruta 23 + 30 + 48',
lines: [
{ account: '4415', debit: 100000, description: 'Inköp mobiler omv. skattskyldighet' },
{ account: '2647', debit: 25000, description: 'Ingående moms omv. skattskyldighet i SE' },
{ account: '2614', credit: 25000 },
{ account: '2440', credit: 100000 },
],
},
{
description: 'Domestic services reverse charge (byggtjänst)',
expectedRutor: 'Ruta 24 + 30 + 48',
lines: [
{ account: '4425', debit: 8000, description: 'Inköp byggtjänster omv.' },
{ account: '2647', debit: 2000 },
{ account: '2614', credit: 2000 },
{ account: '2440', credit: 8000 },
],
},
{
description: 'EU goods sale (intra-community supply, zero-rated)',
expectedRutor: 'Ruta 35',
lines: [
{ account: '1510', debit: 4000 },
{ account: '3108', credit: 4000, description: 'Varuförsäljning till EU' },
],
},
{
description: 'Export sale (non-EU)',
expectedRutor: 'Ruta 36',
lines: [
{ account: '1510', debit: 5000 },
{ account: '3105', credit: 5000, description: 'Varuförsäljning export' },
],
},
{
description: 'EU services sale (B2B, buyer self-assesses)',
expectedRutor: 'Ruta 39',
lines: [
{ account: '1510', debit: 8000 },
{ account: '3308', credit: 8000, description: 'Tjänsteförsäljning till EU' },
],
},
{
description: 'Office supplies purchase (regular input VAT)',
expectedRutor: 'Ruta 48',
lines: [
{ account: '5410', debit: 800, description: 'Förbrukningsinventarier' },
{ account: '2641', debit: 200, description: 'Ingående moms 25%' },
{ account: '2440', credit: 1000 },
],
},
{
description: 'Import from non-EU (paid via Tullverket, VAT to SKV)',
expectedRutor: 'Ruta 50 + 60 + 48',
lines: [
{ account: '4545', debit: 10000, description: 'Beskattningsunderlag import 25%' },
{ account: '2641', debit: 2500, description: 'Ingående moms import' },
{ account: '2615', credit: 2500, description: 'Utgående moms import 25%' },
{ account: '2440', credit: 10000 },
],
},
{
description: 'Owner uttag in kind (only for EF) — comment out if AB',
expectedRutor: 'Ruta 06 + 10',
lines: [
{ account: '2013', debit: 1250, description: 'Egna uttag' },
{ account: '3401', credit: 1000, description: 'Uttag 25%' },
{ account: '2612', credit: 250, description: 'Utgående moms uttag 25%' },
],
},
]
async function ensureFiscalPeriod(): Promise<{ id: string; userId: string }> {
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
// Reuse if it exists
const { data: existing, error: fetchErr } = await supabase
.from('fiscal_periods')
.select('id, user_id')
.eq('company_id', companyId)
.eq('period_start', periodStart)
.eq('period_end', periodEnd)
.maybeSingle()
if (fetchErr) throw new Error(`fiscal_periods select: ${fetchErr.message}`)
if (existing) return { id: existing.id, userId: existing.user_id }
const { data: ownerRow, error: ownerErr } = await supabase
.from('company_members')
.select('user_id')
.eq('company_id', companyId)
.eq('role', 'owner')
.limit(1)
.single()
if (ownerErr || !ownerRow) {
throw new Error(`No owner found for company ${companyId}: ${ownerErr?.message}`)
}
const userId = ownerRow.user_id
const { data: created, error: insertErr } = await supabase
.from('fiscal_periods')
.insert({
company_id: companyId,
user_id: userId,
name: `${year} (SKV test)`,
period_start: periodStart,
period_end: periodEnd,
})
.select('id, user_id')
.single()
if (insertErr || !created) throw new Error(`fiscal_periods insert: ${insertErr?.message}`)
console.log(`Created fiscal period ${created.id} (${periodStart}${periodEnd})`)
return { id: created.id, userId: created.user_id }
}
async function seedScenario(
scenario: Scenario,
fiscalPeriodId: string,
userId: string,
): Promise<void> {
const { description, lines } = scenario
const debit = lines.reduce((s, l) => s + (l.debit ?? 0), 0)
const credit = lines.reduce((s, l) => s + (l.credit ?? 0), 0)
if (Math.abs(debit - credit) > 0.005) {
throw new Error(`Scenario unbalanced: ${description} (D=${debit}, C=${credit})`)
}
// 1. Insert draft entry
const { data: draft, error: draftErr } = await supabase
.from('journal_entries')
.insert({
company_id: companyId,
user_id: userId,
fiscal_period_id: fiscalPeriodId,
voucher_number: 0, // placeholder; commit_journal_entry assigns the real one
voucher_series: 'A',
entry_date: entryDate,
description: `[SKV-TEST] ${description}`,
source_type: 'manual',
status: 'draft',
})
.select('id')
.single()
if (draftErr || !draft) throw new Error(`draft insert (${description}): ${draftErr?.message}`)
// 2. Insert lines
const lineRows = lines.map((l, idx) => ({
journal_entry_id: draft.id,
account_number: l.account,
debit_amount: l.debit ?? 0,
credit_amount: l.credit ?? 0,
line_description: l.description ?? null,
sort_order: idx,
}))
const { error: linesErr } = await supabase.from('journal_entry_lines').insert(lineRows)
if (linesErr) throw new Error(`lines insert (${description}): ${linesErr.message}`)
// 3. Commit via RPC (assigns sequential voucher number, sets status='posted').
// commit_method enum: 'user_accept' | 'bulk_accept' | 'timing_ceiling' |
// 'migration' | 'legacy'. 'migration' is the closest fit for synthetic test
// data inserted outside the normal user-accept flow.
const { data: voucherRow, error: commitErr } = await supabase.rpc('commit_journal_entry', {
p_company_id: companyId,
p_entry_id: draft.id,
p_commit_method: 'migration',
p_rubric_version: null,
})
if (commitErr) throw new Error(`commit (${description}): ${commitErr.message}`)
const voucherNumber = Array.isArray(voucherRow) ? voucherRow[0]?.voucher_number : voucherRow?.voucher_number
console.log(` ✓ A${voucherNumber} ${description.padEnd(60)}${scenario.expectedRutor}`)
}
async function main() {
console.log(`Seeding SKV test data for company ${companyId}, period ${year}-${String(month).padStart(2, '0')}`)
const { id: fiscalPeriodId, userId } = await ensureFiscalPeriod()
for (const scenario of scenarios) {
try {
await seedScenario(scenario, fiscalPeriodId, userId)
} catch (err) {
console.error(`${scenario.description}: ${(err as Error).message}`)
}
}
console.log('\nDone. Verify in /reports → Momsdeklaration:')
console.log(` • Period: ${year}-${String(month).padStart(2, '0')}`)
console.log(' • Expected non-zero Rutor: 05, 06, 10, 11, 12, 21, 22, 23, 24, 30, 35, 36, 39, 48, 50, 60')
console.log('\nClean up later with:')
console.log(` delete from journal_entry_lines where journal_entry_id in (select id from journal_entries where company_id = '${companyId}' and description like '[SKV-TEST]%');`)
console.log(` delete from journal_entries where company_id = '${companyId}' and description like '[SKV-TEST]%';`)
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+93
View File
@@ -0,0 +1,93 @@
/**
* One-off: set the org_number on Arcim Technology AB.
*
* Writes ONE row in `companies`. Verifies the company id, name, and current
* value before writing. Refuses to overwrite a non-null org_number unless
* --force is passed.
*
* Usage: npx tsx scripts/set-arcim-org-number.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, {
auth: { persistSession: false },
})
const [, , companyId, orgNumberRaw, ...flags] = process.argv
const force = flags.includes('--force')
if (!companyId || !orgNumberRaw) {
console.error('Usage: npx tsx scripts/set-arcim-org-number.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]')
process.exit(1)
}
const orgNumber = orgNumberRaw.replace(/[-\s]/g, '')
if (!/^\d{10}$/.test(orgNumber)) {
console.error(`Org number must be 10 digits (got ${orgNumber.length}: "${orgNumberRaw}")`)
process.exit(1)
}
async function main() {
const { data: before, error: readErr } = await supabase
.from('companies')
.select('id, name, org_number, entity_type, archived_at')
.eq('id', companyId)
.single()
if (readErr || !before) {
console.error(`Company ${companyId} not found: ${readErr?.message}`)
process.exit(1)
}
console.log(`Before:`)
console.log(` id: ${before.id}`)
console.log(` name: ${before.name}`)
console.log(` org_number: ${before.org_number ?? '(null)'}`)
console.log(` entity_type: ${before.entity_type}`)
console.log(` archived_at: ${before.archived_at ?? '(null)'}`)
if (before.archived_at) {
console.error('\nCompany is archived. Refusing to update.')
process.exit(1)
}
if (before.org_number && before.org_number !== orgNumber && !force) {
console.error(`\nCompany already has org_number=${before.org_number}. Refusing to overwrite without --force.`)
process.exit(1)
}
if (before.org_number === orgNumber) {
console.log(`\nNo change needed — org_number is already ${orgNumber}.`)
return
}
console.log(`\nUpdating org_number → ${orgNumber}`)
const { data: after, error: updateErr } = await supabase
.from('companies')
.update({ org_number: orgNumber })
.eq('id', companyId)
.select('id, name, org_number, entity_type')
.single()
if (updateErr || !after) {
console.error(`Update failed: ${updateErr?.message}`)
process.exit(1)
}
console.log(`\nAfter:`)
console.log(` org_number: ${after.org_number}`)
const expectedRedovisare =
after.entity_type === 'aktiebolag' ? `16${after.org_number}` : `(EF prefix)`
console.log(` → redovisare for SKV API: ${expectedRedovisare}`)
}
main().catch(err => {
console.error(err)
process.exit(1)
})
+102
View File
@@ -0,0 +1,102 @@
/**
* Update company_settings.org_number for a given company.
*
* The Skatteverket validate handler reads org_number from company_settings
* (not companies), so this is what actually controls the redovisare sent
* to SKV. Mirrors set-arcim-org-number.ts but for the settings table.
*
* Usage: npx tsx scripts/set-company-settings-org.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]
*/
import { createClient } from '@supabase/supabase-js'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
)
const [, , companyId, orgRaw, ...flags] = process.argv
const force = flags.includes('--force')
if (!companyId || !orgRaw) {
console.error('Usage: npx tsx scripts/set-company-settings-org.ts <COMPANY_ID> <ORG_NUMBER_10DIGIT> [--force]')
process.exit(1)
}
const org = orgRaw.replace(/[-\s]/g, '')
if (!/^\d{10}$/.test(org)) {
console.error(`Org number must be 10 digits (got ${org.length})`)
process.exit(1)
}
async function main() {
const { data: before, error: readErr } = await supabase
.from('company_settings')
.select('id, company_id, org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (readErr) {
console.error(`read failed: ${readErr.message}`)
process.exit(1)
}
if (!before) {
// No settings row yet — need to insert. Pull entity_type from companies.
const { data: company } = await supabase
.from('companies')
.select('id, name, entity_type')
.eq('id', companyId)
.single()
if (!company) {
console.error(`Company ${companyId} not found`)
process.exit(1)
}
console.log(`No company_settings row for ${company.name} — inserting one.`)
const { error: insertErr } = await supabase
.from('company_settings')
.insert({ company_id: companyId, org_number: org, entity_type: company.entity_type })
if (insertErr) {
console.error(`insert failed: ${insertErr.message}`)
process.exit(1)
}
console.log(`✓ Inserted company_settings with org_number=${org}, entity_type=${company.entity_type}`)
return
}
console.log(`Before:`)
console.log(` company_id: ${before.company_id}`)
console.log(` org_number: ${before.org_number ?? '(null)'}`)
console.log(` entity_type: ${before.entity_type}`)
if (before.org_number === org) {
console.log(`\nNo change — already ${org}.`)
return
}
if (before.org_number && !force) {
console.error(`\nAlready set to ${before.org_number}. Pass --force to overwrite.`)
process.exit(1)
}
const { data: after, error: updateErr } = await supabase
.from('company_settings')
.update({ org_number: org })
.eq('company_id', companyId)
.select('org_number, entity_type')
.single()
if (updateErr || !after) {
console.error(`update failed: ${updateErr?.message}`)
process.exit(1)
}
const redovisare =
after.entity_type === 'aktiebolag' ? `16${after.org_number}` : `(EF prefix)`
console.log(`\nAfter:`)
console.log(` org_number: ${after.org_number}`)
console.log(` → redovisare for SKV API: ${redovisare}`)
}
main().catch(err => { console.error(err); process.exit(1) })
+41
View File
@@ -0,0 +1,41 @@
-- Set up a gnubok test company that mirrors the testredovisare
-- Skatteverket has wired for your test BankID.
--
-- Replace the three placeholders below before running:
-- :user_id — auth.users.id of the dev user signed in to gnubok
-- (your gnubok login email's user row)
-- :org_number_10digit — the 10-digit form of the testredovisare SKV gave you
-- (e.g. if SKV says 165020000013, use 5020000013)
-- :entity_type — 'aktiebolag' (16-prefix) or 'enskild_firma' (19/20-prefix)
--
-- Run with: psql "$DATABASE_URL" -v user_id="'<uuid>'" -v org_number_10digit="'5020000013'" -v entity_type="'aktiebolag'" -f scripts/setup-skv-test-company.sql
\set ON_ERROR_STOP on
begin;
-- 1. Create the test company.
insert into public.companies (name, org_number, entity_type, created_by)
values ('SKV Test Company', :org_number_10digit, :entity_type, :user_id)
returning id as new_company_id \gset
-- 2. Add the dev user as owner.
insert into public.company_members (company_id, user_id, role)
values (:'new_company_id', :user_id, 'owner');
-- 3. Make this the user's active company.
insert into public.user_preferences (user_id, active_company_id)
values (:user_id, :'new_company_id')
on conflict (user_id) do update set active_company_id = excluded.active_company_id;
-- 4. Seed the BAS chart of accounts (provides 2611, 2641, 4515, etc.).
select public.seed_chart_of_accounts(:'new_company_id', :entity_type);
commit;
\echo
\echo Test company created:
\echo company_id: :new_company_id
\echo org_number: :org_number_10digit
\echo
\echo Next: seed VAT data with
\echo npx tsx scripts/seed-skv-test-data.ts :new_company_id 2026 3
+138
View File
@@ -0,0 +1,138 @@
/**
* Smoke test for other Skatteverket APIs we have OAuth scopes for, using
* the access token already stored from the moms BankID handshake.
*
* Tests:
* 1. inkomstdeklaration2-4 GET /foretag/inkomstdeklaration/v1/{idPers}/perioder
* Scope: inkforetag (already on token)
* Auth host: peroauth2.test (same as moms)
*
* 2. skattekonto v2 GET /beskattning/skattekonto/v2/skattekonton/{omfragad}/saldo
* Scope: ska (already on token)
* Auth host: peroauth.test (different from moms — empirical risk)
*
* 3. skattekonto v2 GET /skattekonton/{omfragad}/transaktioner
* Same scope as #2
*
* Usage: npx tsx scripts/test-skv-other-endpoints.ts <USER_ID> <REDOVISARE_12DIGIT>
*
* Example: npx tsx scripts/test-skv-other-endpoints.ts \
* 9762dd12-7009-4ba2-aa9f-f9966d53e077 161128000013
*
* READ-ONLY against SKV. Won't modify any SKV state — every operation tested
* is a GET. Won't modify the gnubok DB either; just reads the token.
*/
import { createClient } from '@supabase/supabase-js'
import crypto from 'node:crypto'
import { config } from 'dotenv'
import { resolve } from 'node:path'
config({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!
const ENCRYPTION_KEY_RAW = process.env.SKATTEVERKET_TOKEN_ENCRYPTION_KEY!
const APIGW_CLIENT_ID = process.env.SKATTEVERKET_APIGW_CLIENT_ID!
const APIGW_CLIENT_SECRET = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET!
if (!SUPABASE_URL || !SERVICE_KEY || !ENCRYPTION_KEY_RAW || !APIGW_CLIENT_ID || !APIGW_CLIENT_SECRET) {
console.error('Missing required env vars in .env.local')
process.exit(1)
}
const [, , userId, redovisare] = process.argv
if (!userId || !redovisare) {
console.error('Usage: npx tsx scripts/test-skv-other-endpoints.ts <USER_ID> <REDOVISARE_12DIGIT>')
process.exit(1)
}
const encryptionKey = crypto.createHash('sha256').update(ENCRYPTION_KEY_RAW).digest()
function decrypt(ciphertext: string): string {
const combined = Buffer.from(ciphertext, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
async function getAccessToken(): Promise<string> {
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
const { data, error } = await supabase
.from('skatteverket_tokens')
.select('access_token, expires_at, scope')
.eq('user_id', userId)
.single()
if (error || !data) throw new Error(`No token row for user ${userId}: ${error?.message}`)
const accessToken = decrypt(data.access_token)
const expiresAt = new Date(data.expires_at)
if (expiresAt.getTime() < Date.now()) {
throw new Error(`Token expired at ${expiresAt.toISOString()}. Re-authorize via the panel.`)
}
console.log(`Token valid until ${expiresAt.toISOString()}, scope = ${data.scope}`)
return accessToken
}
async function callSkv(label: string, url: string, accessToken: string): Promise<void> {
console.log(`\n--- ${label} ---`)
console.log(`GET ${url}`)
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Client_Id: APIGW_CLIENT_ID,
Client_Secret: APIGW_CLIENT_SECRET,
skv_client_correlation_id: crypto.randomUUID(),
Accept: 'application/json',
},
})
console.log(`Status: ${response.status} ${response.statusText}`)
const ct = response.headers.get('content-type') ?? ''
const body = await response.text()
if (ct.includes('json')) {
try {
const json = JSON.parse(body)
console.log('Body:', JSON.stringify(json, null, 2))
} catch {
console.log('Body (raw):', body.slice(0, 500))
}
} else {
console.log(`Content-Type: ${ct}`)
console.log('Body (first 300 chars):', body.slice(0, 300))
}
}
async function main() {
const accessToken = await getAccessToken()
// 1. inkomstdeklaration2-4 — same OAuth host as moms, requires `inkforetag` scope
await callSkv(
'inkomstdeklaration2-4 — perioder',
`https://api.test.skatteverket.se/foretag/inkomstdeklaration/v1/${redovisare}/perioder`,
accessToken,
)
// 2. skattekonto v2 — declares peroauth.test (different from moms peroauth2.test).
// Test if our existing token is accepted; 401 here means we'd need a separate handshake.
await callSkv(
'skattekonto v2 — saldo',
`https://api.test.skatteverket.se/beskattning/skattekonto/v2/skattekonton/${redovisare}/saldo`,
accessToken,
)
// 3. skattekonto v2 — transaktioner (only meaningful if #2 worked)
await callSkv(
'skattekonto v2 — transaktioner',
`https://api.test.skatteverket.se/beskattning/skattekonto/v2/skattekonton/${redovisare}/transaktioner`,
accessToken,
)
console.log('\nDone (read-only).')
}
main().catch(err => {
console.error('Error:', err.message)
process.exit(1)
})
@@ -0,0 +1,39 @@
-- Add the UNIQUE(user_id) constraint that the original migration
-- 20260324120001_skatteverket_tokens.sql declared but that is missing
-- on at least one deployed environment.
--
-- The application now uses DELETE+INSERT instead of UPSERT for token
-- storage so it works without this constraint, but adding it is still
-- correct: it documents the one-row-per-user invariant and prevents
-- accidental duplicates from any future code path that does INSERT.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = 'public.skatteverket_tokens'::regclass
AND contype = 'u'
AND pg_get_constraintdef(oid) ILIKE '%(user_id)%'
) THEN
-- Defensive: if duplicates somehow exist, keep one row per user.
-- Uses ctid (Postgres physical row identifier) to break ties when
-- multiple rows share the same created_at timestamp — without this,
-- duplicates inserted in the same second wouldn't be deduped and the
-- ALTER TABLE … ADD CONSTRAINT below would fail.
DELETE FROM public.skatteverket_tokens t
USING (
SELECT user_id, max(ctid) AS keep_ctid
FROM public.skatteverket_tokens
GROUP BY user_id
HAVING count(*) > 1
) d
WHERE t.user_id = d.user_id
AND t.ctid <> d.keep_ctid;
ALTER TABLE public.skatteverket_tokens
ADD CONSTRAINT skatteverket_tokens_user_id_key UNIQUE (user_id);
END IF;
END $$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,110 @@
-- Hide cancelled journal entries from the default /bookkeeping list view by
-- filtering them out inside list_fiscal_period_entries_with_related unless
-- the caller explicitly asks for status='cancelled'.
--
-- Why: prior data-cleanup operations (e.g. a one-off SIE re-import for a
-- specific tenant) left ~1,900 cancelled entries in journal_entries for
-- one company. The list view rendered them indistinguishably from posted
-- entries, so the user perceived them as duplicates of the new postings.
-- Hiding cancelled by default matches the convention already used by
-- trial-balance / balance-sheet / income-statement reads (which use
-- .in('status', ['posted', 'reversed'])).
--
-- Implementation: pure body change via CREATE OR REPLACE — no signature
-- change, so old API callers continue to work and immediately benefit from
-- the new behavior the moment this migration applies. No deploy-order risk.
CREATE OR REPLACE FUNCTION public.list_fiscal_period_entries_with_related(
p_company_id uuid,
p_period_id uuid,
p_include_related boolean DEFAULT true,
p_status text DEFAULT NULL,
p_date_from date DEFAULT NULL,
p_date_to date DEFAULT NULL,
p_sort_date text DEFAULT 'desc',
p_limit int DEFAULT 50,
p_offset int DEFAULT 0
)
RETURNS TABLE (
entry jsonb,
total_count bigint
)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path = public, pg_temp
AS $$
WITH period AS (
SELECT period_start, period_end
FROM public.fiscal_periods
WHERE id = p_period_id AND company_id = p_company_id
),
matching AS (
SELECT je.*
FROM public.journal_entries je
CROSS JOIN period p
WHERE je.company_id = p_company_id
AND (
je.fiscal_period_id = p_period_id
OR (
p_include_related
AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note')
AND EXISTS (
SELECT 1 FROM public.invoices i
WHERE i.id = je.source_id
AND i.company_id = p_company_id
AND i.invoice_date BETWEEN p.period_start AND p.period_end
)
)
OR (
p_include_related
AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note')
AND EXISTS (
SELECT 1 FROM public.supplier_invoices si
WHERE si.id = je.source_id
AND si.company_id = p_company_id
AND si.invoice_date BETWEEN p.period_start AND p.period_end
)
)
)
AND (p_status IS NULL OR je.status = p_status)
-- Hide cancelled by default; show them only when caller asks explicitly.
AND (je.status <> 'cancelled' OR p_status = 'cancelled')
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
),
matching_with_total AS (
SELECT m.*, COUNT(*) OVER () AS total
FROM matching m
),
paged AS (
SELECT *
FROM matching_with_total
ORDER BY
CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST,
CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST,
voucher_series,
voucher_number
LIMIT p_limit OFFSET p_offset
)
SELECT
(to_jsonb(p.*) - 'total')
|| jsonb_build_object(
'lines', COALESCE(
(SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order)
FROM public.journal_entry_lines l
WHERE l.journal_entry_id = p.id),
'[]'::jsonb
),
'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id)
) AS entry,
p.total AS total_count
FROM paged p
ORDER BY
CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST,
CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST,
p.voucher_series,
p.voucher_number;
$$;
NOTIFY pgrst, 'reload schema';