Files
accounted/.claude/skills/swedish-e-invoicing/references/implementation-guide.md
T
Jakob WennbergandClaude Opus 4.7 cd64c0e3fb 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>
2026-04-28 18:26:03 +02:00

12 KiB
Raw Blame History

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

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.

# 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

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,000–5,000 active SME customers × 50–100 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.20–0.80 + €5–20k/yr Excellent Multiple APIs, enterprise-flavoured Yes Large enterprise
InExchange SEK 1–5 + 200–400/mo Nordic REST Yes SE-only SME, Visma ecosystem
Maventa (Visma) SEK 0.50–3 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
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 2028–2029 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 1–3

  • 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 4–6

  • 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 7–12

  • 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 €10–15k 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.00–3.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 2029–2031.

The window to build with this advantage is the next 18–24 months before incumbents finish their own native-Peppol projects.

Authoritative source list