fix(peppol): say Peppol send is gated per company, never absent (#546) (#2021)

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:
Jakob Wennberg
2026-08-30 12:19:03 +02:00
committed by GitHub
parent d11d0a2e90
commit d39a9719a3
13 changed files with 53135 additions and 27 deletions
@@ -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)
// 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)
for (const slug of ['invoicing-rules', 'customer-onboarding']) {
const truthfulSkills = ['invoicing-rules', 'customer-onboarding'].map((slug) => {
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')
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 () => {