Peppol sending has been live since #1780 behind a per-company access grant, but the MCP skills, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send / :mark-sent descriptions still told agents it did not exist. Every text now says gated per company (requested under Installningar > Fakturering) and keeps the restrictions explicit: aktiebolag senders, standard invoices only, Swedish org-number buyers, no MCP or v1 Peppol send verb yet, :mark-sent as the recovery step when a network-accepted send fails issuance. The skills guard test pins the truthful claim across all surfaces. Includes the regenerated agent_atom_registry seeds and skills/accounted-api references. Refs #546
This commit is contained in:
@@ -164,9 +164,9 @@ Electronics >100k SEK/invoice?
|
||||
|
||||
---
|
||||
|
||||
## Peppol essentials for externally delivered e-invoices
|
||||
## Peppol e-invoices in Accounted
|
||||
|
||||
Accounted currently creates PDF invoices and can send them by email. It does not generate e-invoice XML or deliver invoices through Peppol. If a customer requires a structured e-invoice, use an external e-invoice provider for delivery, then record the delivery with `gnubok_mark_invoice_as_sent`.
|
||||
Accounted sends Peppol BIS Billing 3 e-invoices from the invoice page in the dashboard (`POST /api/invoices/{id}/peppol/send`, Access Point: Qvalia). Sending is gated per company: access is requested under Inställningar > Fakturering (Settings > Invoicing) and enabled by Accounted's operators; a grant may carry a send cap, and when it is used up the dashboard says so and support raises it. Restrictions: aktiebolag senders only (enskild firma needs a GLN, not yet supported), standard invoices only (no credit notes, no self-billing), Swedish org-number buyers (scheme 0007; a buyer whose org number is a personnummer, i.e. an enskild firma customer, is refused until GLN identifiers are supported), SEK invoices with taxable Swedish VAT at 6, 12 or 25 % only (no reverse charge, no VAT-exempt sales, no ROT/RUT deductions), and Er referens (BuyerReference) set. No MCP tool or v1 API action sends via Peppol yet. Never describe Peppol sending as absent; it is gated per company. A successful dashboard Peppol send issues the invoice itself, so do not mark it as sent afterwards. If the dashboard reports that the invoice was sent via Peppol but could not be marked as sent, complete the issuance with `gnubok_mark_invoice_as_sent` on the still-draft invoice (if it already shows as sent, the verifikat exists and only its link to the invoice needs repair: do not mark it as sent again, leave the repair to support). Where access is not granted, or the sender is an enskild firma, use an external e-invoice provider for delivery, then record the delivery with `gnubok_mark_invoice_as_sent`.
|
||||
|
||||
Format: UBL 2.1 XML, profile Peppol BIS Billing 3.0.
|
||||
TypeCodes: **380** = invoice, **381** = credit note, **389** = self-billing.
|
||||
|
||||
@@ -1340,6 +1340,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
|
||||
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
|
||||
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
|
||||
[2026-08-28] Skill, atom and API-doc texts must never claim a capability is absent when it is gated (#546): Peppol send has been live since 2026-08-21 behind a per-company access grant (app/api/invoices/[id]/peppol/send, scripts/peppol/access.ts), yet invoicing-rules, customer-onboarding, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send/:mark-sent descriptions still told agents it did not exist, which sends B2G users to a competitor. Every touched text now says "gated per company, request under Inställningar > Fakturering", keeps the restrictions explicit (aktiebolag senders only, standard invoices only, no MCP or v1 send verb yet) so agents do not over-promise to enskild firma users, and keeps the external-provider + gnubok_mark_invoice_as_sent fallback for companies without access. The skills guard test was inverted from pinning "no Peppol send" to pinning the truthful claim, so a future regression in either direction fails CI.
|
||||
[2026-08-28] Generated agent_atom_registry seed renamed from the generator's max-plus-one version (20260828220001) to 20260829000100 (#546): the migration rule for this batch is a version newer than every existing file and dated after today's parallel worktrees, which also emit seeds with generator-picked 20260828 names; skills:check compares the content-hash manifest, not the filename, and the pg replay test globs the seed, so the rename is inert for both.
|
||||
[2026-08-29] PR #2021 review round (#546): the v1 :send/:mark-sent descriptions now say :mark-sent is not needed after a SUCCESSFUL dashboard Peppol send and IS the documented recovery when the network accepted the document but issuance failed (send route returns 201 with issuance.ok=false and the invoice still draft; the dashboard toast peppol_issue_failed_description points the user to Markera som skickad; ensureInvoiceNumber is idempotent so no second F-number is consumed). The unconditional "never call :mark-sent after a Peppol send" would have made an agent refuse the one action that completes the bookkeeping. The relayed restriction list in all three agent texts gained the exporter's other refusals (SEK, taxable 6/12/25 % VAT only, no ROT/RUT deductions, Er referens) and the send cap, because the ROT/RUT section sits directly above the Peppol section in invoicing-rules and a bygg company would otherwise be promised a send the exporter refuses. The atom edit was seeded as a SECOND generated migration (20260829010000, atom v8) next to the PR's 20260829000100 (v7) rather than editing that file in place: the generator is append-only by design, and the Supabase preview branch for the PR has already applied 20260829000100, so replacing or removing it would orphan the preview's migration history; both seeds are idempotent upserts with the version guard, so prod applying both is harmless. The guard test's "capability absent" patterns were re-anchored on the capability as subject (active, passive and adjective forms, each proven against the pre-#546 wording inline) so the true v1 sentence "a v1 or MCP Peppol send action is not yet available" stays legal and a passive rewording no longer evades the test.
|
||||
[2026-08-29] Bank 1:N (#1553, one bank row over several verifikat) is stored junction-only: transactions.journal_entry_id stays NULL and one transaction_voucher_links row per verifikat carries a signed role='bank_line' slice in the transaction's sign convention (the same shape bulk_book_transactions writes), never a pointer at "the first" voucher: the pointer column is a 1:1 fact, every reader that matters already asks isTransactionBooked / is_transaction_booked() / the GL RPCs' junction count, and a pointer at one of N would make koppla-bort, storno and the bridge all disagree about which voucher the row "belongs" to. The slices must sum to the row (VOUCHER_LINK_AMOUNT_TOLERANCE), each must have the sign of, and be no larger than, that voucher's net line on the account; the locked transactions UPDATE (.is journal_entry_id null, or .eq the stale pointer of a reversed entry per #988) runs BEFORE the one junction insert so a concurrent linker matches zero rows, and a failed insert rolls the lock back.
|
||||
[2026-08-29] Storno of one of a split's N vouchers releases the row WHOLE (engine reverseEntry): after the reversed voucher's junction rows go, a pointer-less row whose remaining anchors are all role='bank_line' but no longer sum to its amount has its surviving slices deleted and returns to Att bokföra (is_business/category/reconciliation_method null), so the surviving verifikat resurface as unmatched and the user re-splits against the corrected booking. The alternative (keep the partial anchor) would show the row as booked while the bridge moved by the reversed slice with nothing to act on. Rows whose remaining slices still cover the amount (bulk-book, one row per transaction) and rows with a non-bank_line anchor (a residual's 'other' row) are untouched, as before.
|
||||
[2026-08-29] The re-booking guards (manualLink, categorize-core, link-journal-entry) refuse on a transaction_voucher_links row of role 'bank_line' only (hasBankLineJunctionRow), not on any junction row, while the list/ignore readers (fetchJunctionLinkedTxIds, is_transaction_booked()) keep counting every role: a residual booking's 'other' row survives a storno of the main verifikat, which also nulls reconciliation_method so koppla-bort refuses the row; refusing to re-book it would strand it with no path out. linkTransactionToVouchers itself stays strict (any junction row blocks a split) because the UNIQUE (transaction_id, journal_entry_id) key would refuse re-anchoring that voucher anyway.
|
||||
@@ -1354,3 +1357,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload).
|
||||
[2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason.
|
||||
[2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff.
|
||||
[2026-08-30] PR #2021 round 2 (#546): the relayed Peppol buyer restriction now says the customer's org number must not be a personnummer (prepareParty('buyer') in lib/invoices/peppol-bis-billing.ts refuses it with BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED, so an enskild firma CUSTOMER is refused, not only an enskild firma sender), Step 4 of the invoicing-rules workflow points at the Peppol section so a top-down reader never reaches the external-provider fallback first, the mark-sent recovery is scoped to the still-draft invoice in every text (INVOICE_MARK_SENT_REPAIR_REQUIRED leaves the invoice sent with the verifikat posted and a second mark-sent returns 409; the reviewer's proposed repair tool gnubok_link_invoice_to_voucher is the PAYMENT link and requires status sent/overdue/partially_paid, so no tool is named and the repair is left to support), and the verifikat parenthetical says "under faktureringsmetoden" (kontantmetod and defer_invoice_booking companies get none at issue). The guard test now also pins the two v1 route descriptions by reading the route source (apiskill:check only detects generated-vs-source drift, not a truth regression). The atom bump was seeded as a THIRD append-only migration (20260830101500, atom v9) rather than consolidating to one: the Supabase preview branch for the PR (xxnqggttsefleehmarjo) has applied both 20260829000100 and 20260829010000 per its schema_migrations, so deleting either would leave a remote with versions absent from the repo, the orphan class the migration rule forbids; all three seeds are idempotent upserts with the version guard, so prod applying them in sequence ends at v9. The generator's max-plus-one name (20260829010001) was renamed to 20260830101500 for the same reason as round 1 (newer than every file on origin/main and every sibling worktree; skills:check hashes content, the pg replay test globs the seed).
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
* POST /api/v1/companies/{companyId}/invoices/{id}/mark-sent
|
||||
*
|
||||
* Transitions a DRAFT invoice to `sent` status. Use this for invoices
|
||||
* delivered outside the system (Peppol, postal, custom email). The full
|
||||
* delivered outside the system (external e-invoice provider, postal,
|
||||
* custom email). A successful dashboard Peppol send
|
||||
* (POST /api/invoices/{id}/peppol/send) issues the invoice itself; this
|
||||
* endpoint is the documented recovery only when that send was accepted by
|
||||
* the network but issuance failed (invoice still draft). The full
|
||||
* :send pipeline (PDF + email) will land in PR-B-2b-3.
|
||||
*
|
||||
* What happens on commit:
|
||||
@@ -78,9 +82,9 @@ registerEndpoint({
|
||||
path: '/api/v1/companies/:companyId/invoices/:id/mark-sent',
|
||||
summary: 'Transition a draft invoice to sent (without emailing).',
|
||||
description:
|
||||
'Marks a draft invoice as sent: for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). When the company books at issue (faktureringsmetoden without defer_invoice_booking), also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
|
||||
'Marks a draft invoice as sent: for invoices delivered outside Accounted (an external e-invoice provider, postal, manual email). Not needed after a successful dashboard Peppol send: that flow issues the invoice itself. If the dashboard reports that the invoice was sent via Peppol but could not be marked as sent (the send response carried issuance.ok=false and the invoice is still in draft), :mark-sent is the documented recovery and completes the issuance; a number already allocated is reused, never consumed twice. Peppol sending lives in the dashboard invoice page behind a per-company access grant (requested under Inställningar > Fakturering (Settings > Invoicing); aktiebolag senders, standard invoices only, Swedish org-number buyers whose org number is not a personnummer, SEK with taxable Swedish VAT at 6/12/25 % only, no ROT/RUT deductions); a v1 or MCP Peppol send action is not yet available. Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). When the company books at issue (faktureringsmetoden without defer_invoice_booking), also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.',
|
||||
useWhen:
|
||||
'You delivered the invoice through a channel other than Accounted\'s email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.',
|
||||
'You delivered the invoice through a channel other than Accounted\'s email or a successful dashboard Peppol send (an external e-invoice provider, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted; or a dashboard Peppol send was accepted by the network but reported that the invoice could not be marked as sent.',
|
||||
doNotUseFor:
|
||||
'Sending the invoice via Accounted email: use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid: use :mark-paid (PR-B-2b-2).',
|
||||
pitfalls: [
|
||||
|
||||
@@ -125,7 +125,7 @@ registerEndpoint({
|
||||
description:
|
||||
'The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via the email extension (Resend or SMTP; PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.',
|
||||
useWhen:
|
||||
'You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.',
|
||||
'You want Accounted to deliver the invoice to the customer via email. Peppol e-invoices are sent from the invoice page in the dashboard (per-company access grant requested under Inställningar > Fakturering (Settings > Invoicing); aktiebolag senders, standard invoices only, Swedish org-number buyers whose org number is not a personnummer, SEK with taxable Swedish VAT at 6/12/25 % only, no ROT/RUT deductions); a v1 or MCP Peppol send action is not yet available. A successful dashboard Peppol send issues the invoice itself, so do not call :mark-sent after it; only if the dashboard reports that the invoice was sent via Peppol but could not be marked as sent does :mark-sent complete the issuance. For invoices delivered through another channel (an external e-invoice provider, postal, own SMTP) use :mark-sent instead.',
|
||||
doNotUseFor:
|
||||
'Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).',
|
||||
pitfalls: [
|
||||
|
||||
@@ -13,7 +13,7 @@ The first slice implemented the invoice profile. The second slice added an immut
|
||||
|
||||
`POST /api/invoices/{id}/peppol` now stores the exact generated XML as an immutable staged delivery. Staging assigns a stable UUID idempotency key, stores the recipient, profile identifiers, filename, SHA-256, retention date, and an append-only local audit event. It explicitly returns `network_submitted: false`. Repeating the request for the same invoice and XML returns the existing staged record.
|
||||
|
||||
`GET /api/invoices/{id}/peppol/deliveries` returns a minimized status timeline projection without exposing XML, raw webhooks, or provider evidence. The invoice page can prepare a delivery, but its network send control remains disabled with a provider-required explanation.
|
||||
`GET /api/invoices/{id}/peppol/deliveries` returns a minimized status timeline projection without exposing XML, raw webhooks, or provider evidence. The invoice page can prepare a delivery; since 2026-08-21 its send control performs the network send through `POST /api/invoices/{id}/peppol/send` for companies holding a Peppol access grant (aktiebolag senders, standard invoices only; see "Access point: Qvalia" below).
|
||||
|
||||
The provider-neutral `PeppolTransport` boundary separates:
|
||||
|
||||
@@ -22,7 +22,7 @@ The provider-neutral `PeppolTransport` boundary separates:
|
||||
- cryptographically verified webhook normalization;
|
||||
- evidence retrieval, including an optional exact transmitted document.
|
||||
|
||||
No adapter is registered by core and no environment value can make an absent adapter appear available.
|
||||
Core registers an adapter only when provider credentials are present in the environment (`lib/init.ts`); no environment value can make an absent adapter appear available.
|
||||
|
||||
The export supports:
|
||||
|
||||
@@ -43,6 +43,8 @@ The local preflight is not a replacement for the official validation stack. Befo
|
||||
|
||||
## Remaining architecture
|
||||
|
||||
The subsections below are the requirements written before the Qvalia decision. Access-point delivery and the UI send flow shipped on 2026-08-21 and are described under "Access point: Qvalia"; the text is kept as the acceptance criteria that work met. Standards validation (the release-pinned validator run before transport) is still open.
|
||||
|
||||
### Standards validation
|
||||
|
||||
Pin and execute the official release artifacts from OpenPeppol and the EN 16931 validation artifact registry. The November 2025 release is the active production release at implementation time. The May 2026 release becomes mandatory on 17 August 2026, so provider onboarding and conformance testing must target the May 2026 validator before launch.
|
||||
@@ -78,7 +80,7 @@ Inbound invoices are a separate acceptance slice. It requires provider webhook a
|
||||
|
||||
### UI and API
|
||||
|
||||
The UI downloads a locally checked XML file and can prepare an immutable delivery snapshot. Both actions state that they did not send the invoice. Once an adapter exists, sending must be a distinct confirmation flow that performs recipient lookup, shows the discovered participant and capabilities, and records the resulting timeline. The download remains available for diagnosis and interoperability testing.
|
||||
The UI downloads a locally checked XML file and can prepare an immutable delivery snapshot. Both actions state that they did not send the invoice. Once an adapter exists, sending must be a distinct confirmation flow that performs recipient lookup, shows the discovered participant and capabilities, and records the resulting timeline. The download remains available for diagnosis and interoperability testing. Shipped 2026-08-21: the confirmation dialog on the invoice page and `POST /api/invoices/{id}/peppol/send` (see "Access point: Qvalia").
|
||||
|
||||
### Access point: Qvalia (decided 2026-08-21)
|
||||
|
||||
|
||||
@@ -140,23 +140,112 @@ describe('Skills registry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not advertise unsupported built-in e-invoice delivery', () => {
|
||||
it('describes Peppol sending truthfully: gated per company, never absent (#546)', () => {
|
||||
const invoiceComplianceAtom = readFileSync(
|
||||
join(process.cwd(), '.claude/skills/swedish-invoice-compliance/SKILL.md'),
|
||||
'utf8',
|
||||
)
|
||||
const allBodies = [...skills.map((skill) => skill.body), invoiceComplianceAtom].join('\n')
|
||||
|
||||
expect(allBodies).not.toMatch(/Accounted\s+(?:renders|generates|produces)[^.\n]*EN\s*16931/i)
|
||||
expect(allBodies).not.toMatch(/Accounted\s+(?:handles|sends|delivers)[^.\n]*Peppol/i)
|
||||
|
||||
for (const slug of ['invoicing-rules', 'customer-onboarding']) {
|
||||
const skill = skills.find((candidate) => candidate.slug === slug)
|
||||
expect(skill?.body).toMatch(/external e-invoice provider/i)
|
||||
expect(skill?.body).toContain('gnubok_mark_invoice_as_sent')
|
||||
// The release-pinned EN 16931 validation stack is still open (docs/PEPPOL_FOUNDATION.md),
|
||||
// so no text may claim Accounted validates against it.
|
||||
expect(allBodies).not.toMatch(/Accounted\s+(?:renders|generates|produces|validates)[^.\n]*EN\s*16931/i)
|
||||
// Peppol send is live behind a per-company access grant (app/api/invoices/[id]/peppol/send).
|
||||
// A text that claims the capability is absent sends users to a competitor; say gated instead.
|
||||
// The patterns take the capability as subject (active, passive and adjective forms) so that
|
||||
// the true statement "a v1 or MCP Peppol send action is not yet available" stays legal.
|
||||
const capabilityAbsentPatterns = [
|
||||
/(?:does not|doesn't|cannot|can't)[^.\n]*(?:send|deliver|generate)[^.\n]*Peppol/i,
|
||||
/Peppol\s+(?:invoices?|e-invoices?|documents?|send(?:ing)?|delivery)\s+(?:is|are|has|have)\s+(?:not|never)\b[^.\n]*\b(?:sent|delivered|generated|built|implemented|available|supported|possible)\b/i,
|
||||
]
|
||||
for (const pattern of capabilityAbsentPatterns) {
|
||||
expect(allBodies).not.toMatch(pattern)
|
||||
// Forbidden: the pre-#546 wording in its active, passive and adjective forms.
|
||||
expect(
|
||||
[
|
||||
'It does not generate e-invoice XML or deliver invoices through Peppol.',
|
||||
'Peppol invoices are not generated or sent by Accounted.',
|
||||
'Peppol sending has not been built.',
|
||||
'Peppol sending is not yet available.',
|
||||
].some((claim) => pattern.test(claim)),
|
||||
).toBe(true)
|
||||
// Legal: the v1 :send/:mark-sent descriptions say the agent verb is missing, not the capability.
|
||||
expect('a v1 or MCP Peppol send action is not yet available').not.toMatch(pattern)
|
||||
}
|
||||
// No MCP tool or v1 action sends via Peppol yet: no text may hand an agent a Peppol send verb.
|
||||
expect(allBodies).not.toMatch(/gnubok_send_invoice[^.\n]*Peppol/i)
|
||||
expect(allBodies).not.toMatch(/gnubok_send_peppol|gnubok_peppol_send/i)
|
||||
|
||||
const truthfulSkills = ['invoicing-rules', 'customer-onboarding'].map((slug) => {
|
||||
const skill = skills.find((candidate) => candidate.slug === slug)
|
||||
expect(skill, `skill ${slug}`).toBeTruthy()
|
||||
return skill!
|
||||
})
|
||||
// The discovery surface (gnubok_list_skills) must not frame e-invoicing as external either.
|
||||
for (const skill of truthfulSkills) {
|
||||
expect(skill.summary).not.toMatch(/external e-invoic/i)
|
||||
}
|
||||
expect(truthfulSkills[0].summary).toMatch(/Peppol/)
|
||||
|
||||
const truthfulTexts = [...truthfulSkills.map((skill) => skill.body), invoiceComplianceAtom]
|
||||
for (const text of truthfulTexts) {
|
||||
// Where it lives, and that it is gated per company (with the English label for en-locale users).
|
||||
expect(text).toMatch(/invoice page in the dashboard/i)
|
||||
expect(text).toMatch(/(?:gated|access)[^.\n]*per company|per[- ]company[^.\n]*(?:access|gated)/i)
|
||||
expect(text).toContain('Inställningar > Fakturering (Settings > Invoicing)')
|
||||
expect(text).toMatch(/send cap/i)
|
||||
// The restrictions agents must not over-promise past (lib/invoices/peppol-bis-billing.ts).
|
||||
expect(text).toMatch(/aktiebolag/i)
|
||||
expect(text).toMatch(/enskild firma/i)
|
||||
expect(text).toMatch(/standard invoices only|no credit notes/i)
|
||||
expect(text).toMatch(/SEK/)
|
||||
expect(text).toMatch(/6, 12 or 25 %/)
|
||||
expect(text).toMatch(/no reverse charge/i)
|
||||
expect(text).toMatch(/no ROT\/RUT deductions/i)
|
||||
expect(text).toMatch(/Er referens/)
|
||||
// No agent-callable send verb yet.
|
||||
expect(text).toMatch(/no MCP tool[^.\n]*Peppol|MCP tool[^.\n]*not (?:yet )?available/i)
|
||||
// A successful dashboard send issues the invoice; mark-sent is only the issuance-failure recovery.
|
||||
expect(text).toMatch(/successful dashboard Peppol send issues the invoice itself/i)
|
||||
expect(text).toMatch(/could not be marked as sent/i)
|
||||
// The fallback path stays documented for companies without access.
|
||||
expect(text).toMatch(/external e-invoice provider/i)
|
||||
expect(text).toContain('gnubok_mark_invoice_as_sent')
|
||||
// The exporter refuses personnummer-based BUYER identifiers too (prepareParty('buyer') in
|
||||
// peppol-bis-billing.ts), so an enskild firma customer must not be promised a send.
|
||||
expect(text).toMatch(/(?:buyer|customer)[^.\n]*personnummer/i)
|
||||
// The mark-sent recovery applies to the still-draft invoice only: INVOICE_MARK_SENT_REPAIR_REQUIRED
|
||||
// leaves the invoice sent with the verifikat posted, and a second mark-sent returns 409.
|
||||
expect(text).toMatch(/still-draft invoice/i)
|
||||
}
|
||||
|
||||
// The numbered workflow must route an e-invoice customer to the Peppol section from Step 4
|
||||
// itself, so an agent reading top-down never reaches the external-provider fallback first.
|
||||
const invoicingRules = truthfulSkills[0].body
|
||||
expect(invoicingRules).toMatch(/### Step 4: Send[\s\S]*?Peppol[\s\S]*?### Step 5/)
|
||||
// Kontantmetod and defer_invoice_booking companies get no verifikat at issue (Step 3 says the same).
|
||||
expect(invoicingRules).toMatch(/issues the invoice itself \(number, status, and the verifikat under faktureringsmetoden\)/)
|
||||
|
||||
// The v1 :send / :mark-sent descriptions (source of skills/accounted-api/references/invoices.md)
|
||||
// are the fourth and fifth corrected surfaces; apiskill:check only detects generated-vs-source
|
||||
// drift, so the truthful claim is pinned here on the route source itself.
|
||||
const v1RouteTexts = [
|
||||
'app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts',
|
||||
'app/api/v1/companies/[companyId]/invoices/[id]/mark-sent/route.ts',
|
||||
].map((relativePath) => readFileSync(join(process.cwd(), relativePath), 'utf8'))
|
||||
for (const text of v1RouteTexts) {
|
||||
for (const pattern of capabilityAbsentPatterns) {
|
||||
expect(text).not.toMatch(pattern)
|
||||
}
|
||||
// The pre-#546 framing listed Peppol as an external channel next to postal mail.
|
||||
expect(text).not.toMatch(/\(Peppol, postal/)
|
||||
expect(text).toMatch(/a v1 or MCP Peppol send action is not yet available/)
|
||||
expect(text).toMatch(/per-company access grant/)
|
||||
expect(text).toContain('Inställningar > Fakturering (Settings > Invoicing)')
|
||||
expect(text).toMatch(/aktiebolag senders, standard invoices only/)
|
||||
expect(text).toMatch(/buyers whose org number is not a personnummer/)
|
||||
expect(text).toMatch(/could not be marked as sent/)
|
||||
}
|
||||
expect(invoiceComplianceAtom).toMatch(/external e-invoice provider/i)
|
||||
expect(invoiceComplianceAtom).toContain('gnubok_mark_invoice_as_sent')
|
||||
})
|
||||
|
||||
it('findSkill resolves the workflow skill or null (sync workflow lookup)', async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ later: much cheaper to ask the customer once at onboarding.
|
||||
|
||||
**Special cases:**
|
||||
|
||||
- **B2G (Swedish municipality/agency)**: still \`swedish_business\`. If the customer requires Peppol or another e-invoice format, deliver the invoice through an external e-invoice provider, then use \`gnubok_mark_invoice_as_sent\` to record delivery. Accounted currently creates PDF/email invoices and does not generate or send Peppol invoices.
|
||||
- **B2G (Swedish municipality/agency)**: still \`swedish_business\`. Public-sector buyers require Peppol e-invoices. Accounted sends Peppol BIS Billing 3 invoices from the invoice page in the dashboard, gated per company: request access under Inställningar > Fakturering (Settings > Invoicing), Accounted's operators enable it, and a grant may carry a send cap that support raises. Restrictions: aktiebolag senders only, standard invoices only (no credit notes), a buyer whose org number is not a personnummer (an enskild firma customer is refused until GLN identifiers are supported), SEK invoices with taxable Swedish VAT at 6, 12 or 25 % only (no reverse charge, no ROT/RUT deductions), and Er referens set. No MCP tool sends via Peppol yet, so the user sends from the dashboard. A successful dashboard Peppol send issues the invoice itself, so do not mark it as sent afterwards; if the dashboard reports that the invoice was sent via Peppol but could not be marked as sent, complete the issuance with \`gnubok_mark_invoice_as_sent\` on the still-draft invoice (if it already shows as sent, the verifikat exists and only its link to the invoice needs repair: do not mark it as sent again, leave the repair to support). If Peppol access is not granted, or the company is an enskild firma, deliver the invoice through an external e-invoice provider, then use \`gnubok_mark_invoice_as_sent\` to record delivery.
|
||||
- **Consumer customer in another EU country (B2C distance sale)**: \`eu_business\` does NOT apply. Charge Swedish VAT (25/12/6 %) below the OSS threshold; above the threshold the company must register for OSS. This is rare for sole traders: flag the user if turnover suggests they're approaching the threshold.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -83,6 +83,8 @@ Returns staged operation. User approves in web app → invoice number is allocat
|
||||
|
||||
If the user delivered the invoice manually (printed or sent through an external e-invoice provider), use \`gnubok_mark_invoice_as_sent\` instead: same booking effect, no email.
|
||||
|
||||
For a customer that requires an e-invoice (B2G, or a buyer that asks for Peppol), see "E-invoicing via Peppol" below: the user sends from the invoice page in the dashboard, gated per company; no MCP tool sends via Peppol yet.
|
||||
|
||||
### Step 5: Record payment
|
||||
|
||||
When money arrives in 1930:
|
||||
@@ -112,9 +114,9 @@ For consumer-targeted services (RUT: städning, RUT) or construction (ROT):
|
||||
|
||||
This data goes on the invoice; Accounted's invoice template renders it automatically when set on the customer.
|
||||
|
||||
## External e-invoicing (including B2G)
|
||||
## E-invoicing via Peppol (including B2G)
|
||||
|
||||
Accounted currently creates PDF invoices and can send them by email. It does not generate e-invoice XML or deliver invoices through Peppol. If the customer requires an e-invoice, deliver it through an external e-invoice provider, then use \`gnubok_mark_invoice_as_sent\` to record the delivery and apply the same booking effect without sending another email.
|
||||
Accounted sends Peppol BIS Billing 3 e-invoices from the invoice page in the dashboard. Peppol sending is gated per company: the user requests access under Inställningar > Fakturering (Settings > Invoicing) and Accounted's operators enable it; a grant may carry a send cap, and when it is used up the dashboard says so and support raises it. Restrictions: the sending company must be an aktiebolag (enskild firma is refused until GLN identifiers are supported), standard invoices only (no credit notes, no self-billed invoices), the customer must be a Swedish business or organization whose org number is not a personnummer (an enskild firma customer is refused until GLN identifiers are supported), and the invoice must be in SEK with taxable Swedish VAT at 6, 12 or 25 % (no reverse charge, no VAT-exempt sales, no ROT/RUT deductions) and carry Er referens. There is no MCP tool and no v1 API action for Peppol sending yet, so an agent cannot trigger it: tell the user to send from the invoice page. Never tell a user that Accounted lacks Peppol sending; say it is gated per company. A successful dashboard Peppol send issues the invoice itself (number, status, and the verifikat under faktureringsmetoden), so do not mark it as sent afterwards. If the dashboard reports that the invoice was sent via Peppol but could not be marked as sent, call \`gnubok_mark_invoice_as_sent\` on the still-draft invoice to complete the issuance; a number already allocated is reused, never consumed twice. If that invoice already shows as sent, the number and the verifikat exist and only the invoice's link to the verifikat needs repair: do not mark it as sent again (it returns 409), leave the repair to support. If the company has no Peppol access, or is an enskild firma, and the customer requires an e-invoice, deliver it through an external e-invoice provider, then use \`gnubok_mark_invoice_as_sent\` to record the delivery and apply the same booking effect without sending another email.
|
||||
|
||||
## Critical rules
|
||||
|
||||
@@ -133,7 +135,7 @@ Accounted currently creates PDF invoices and can send them by email. It does not
|
||||
- \`gnubok_list_customers\` / \`gnubok_create_customer\`: customer setup
|
||||
- \`gnubok_create_invoice\`: stage new invoice
|
||||
- \`gnubok_send_invoice\`: email PDF
|
||||
- \`gnubok_mark_invoice_as_sent\`: manual delivery
|
||||
- \`gnubok_mark_invoice_as_sent\`: manual delivery (also the recovery when a dashboard Peppol send could not mark the invoice as sent)
|
||||
- \`gnubok_mark_invoice_as_paid\`: manual payment
|
||||
- \`gnubok_match_transaction_to_invoice\`: link bank payment
|
||||
- \`gnubok_credit_invoice\`: kreditfaktura (legal undo)
|
||||
@@ -146,7 +148,7 @@ Accounted currently creates PDF invoices and can send them by email. It does not
|
||||
export const invoicingRulesSkill: Skill = {
|
||||
slug: 'invoicing-rules',
|
||||
name: 'Invoicing Rules',
|
||||
summary: 'Mandatory invoice fields (ML 17 kap. 24 §), VAT treatment, ROT/RUT, external e-invoicing, kreditfaktura.',
|
||||
summary: 'Mandatory invoice fields (ML 17 kap. 24 §), VAT treatment, ROT/RUT, Peppol e-invoicing (gated per company), kreditfaktura.',
|
||||
tags: ['invoicing', 'vat', 'compliance', 'eu', 'rot-rut'],
|
||||
body,
|
||||
tier: 'workflow',
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
"version": 1
|
||||
},
|
||||
"horizontal/swedish-invoice-compliance": {
|
||||
"hash": "1ce94ae22e82bec31fdcfbf28d95d225097d150e1be0631bb1c3c41aa172bd44",
|
||||
"version": 6
|
||||
"hash": "76716727dc49b3c0f79a570039de577c2aa03d266413d8fe0853607540e3397c",
|
||||
"version": 9
|
||||
},
|
||||
"horizontal/swedish-invoice-compliance/invoice-rules": {
|
||||
"hash": "00f8b5a1e2883595f470216f1200d88481de9dc6aab3520bac7ab4589922a386",
|
||||
|
||||
@@ -529,9 +529,9 @@ Example response `200`:
|
||||
**Transition a draft invoice to sent (without emailing).**
|
||||
`scope:invoices:write · risk:medium · idempotent · dry-run`
|
||||
|
||||
Marks a draft invoice as sent: for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). When the company books at issue (faktureringsmetoden without defer_invoice_booking), also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.
|
||||
Marks a draft invoice as sent: for invoices delivered outside Accounted (an external e-invoice provider, postal, manual email). Not needed after a successful dashboard Peppol send: that flow issues the invoice itself. If the dashboard reports that the invoice was sent via Peppol but could not be marked as sent (the send response carried issuance.ok=false and the invoice is still in draft), :mark-sent is the documented recovery and completes the issuance; a number already allocated is reused, never consumed twice. Peppol sending lives in the dashboard invoice page behind a per-company access grant (requested under Inställningar > Fakturering (Settings > Invoicing); aktiebolag senders, standard invoices only, Swedish org-number buyers whose org number is not a personnummer, SEK with taxable Swedish VAT at 6/12/25 % only, no ROT/RUT deductions); a v1 or MCP Peppol send action is not yet available. Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). When the company books at issue (faktureringsmetoden without defer_invoice_booking), also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow.
|
||||
|
||||
**Use when:** You delivered the invoice through a channel other than Accounted's email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted.
|
||||
**Use when:** You delivered the invoice through a channel other than Accounted's email or a successful dashboard Peppol send (an external e-invoice provider, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted; or a dashboard Peppol send was accepted by the network but reported that the invoice could not be marked as sent.
|
||||
**Do not use for:** Sending the invoice via Accounted email: use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid: use :mark-paid (PR-B-2b-2).
|
||||
|
||||
**Pitfalls:**
|
||||
@@ -616,7 +616,7 @@ Response `200` (`application/pdf`).
|
||||
|
||||
The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via the email extension (Resend or SMTP; PDF attachment, copy to company) → flip status to sent → post journal entry (real invoice, unless kontantmetoden or defer_invoice_booking) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent.
|
||||
|
||||
**Use when:** You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead.
|
||||
**Use when:** You want Accounted to deliver the invoice to the customer via email. Peppol e-invoices are sent from the invoice page in the dashboard (per-company access grant requested under Inställningar > Fakturering (Settings > Invoicing); aktiebolag senders, standard invoices only, Swedish org-number buyers whose org number is not a personnummer, SEK with taxable Swedish VAT at 6/12/25 % only, no ROT/RUT deductions); a v1 or MCP Peppol send action is not yet available. A successful dashboard Peppol send issues the invoice itself, so do not call :mark-sent after it; only if the dashboard reports that the invoice was sent via Peppol but could not be marked as sent does :mark-sent complete the issuance. For invoices delivered through another channel (an external e-invoice provider, postal, own SMTP) use :mark-sent instead.
|
||||
**Do not use for:** Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path).
|
||||
|
||||
**Pitfalls:**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user