Files
accounted/lib/invoices/peppol-registration.ts
T
f31eeaa603 feat(connect): Peppol through the connector (hosted proxy, instance transport, ownership ledger) (#2177)
* feat(connect): peppol connector foundation: capability, ledger/budget service, quota

Adds the storage + package shape for brokering Peppol through the connector with
the same one-address + rate-budget model as bank/skatteverket: a peppol
capability (connector-gated, free on hosted), peppol as a ledger + upstream
service, a conservative rate budget, and a migration extending the ledger
service CHECK and the per-key limits (peppol_connections_per_company). Proxy
route + instance-side Qvalia reroute follow. Switch-on gated on the Qvalia
brokering-terms check.

(cherry picked from commit 3cc0da6a3, migration renumbered 20260902190000)

Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat

* feat(connect): Peppol through the connector: hosted proxy, instance transport, ownership ledger

Completes the Peppol upstream for self-hosted instances on the connector
(WS3): an instance with a connector key carrying the peppol scope and no
Qvalia keys of its own sends and receives e-invoices through Arcim's
contracted access point, the same way bank and Skatteverket already route.

Hosted: app/api/connect/peppol/[...path] speaks the PeppolTransport
operations (lookup, submit, status, evidence, recipient PUT/DELETE, inbound
list/xml) rather than proxying Qvalia paths, because the Qvalia account is
shared by every hosted company and every instance: reads must be scoped to
what the caller owns, and the inbound read endpoint is destructive for the
whole account. Ownership: a receiving registration is a ledger row (service
peppol, participant id in account_uids, sha256 in handle_hash so one key
holds a participant at a time); outbound submissions land in the new
connector_peppol_submissions table and gate status/evidence; inbound
documents are served from the hosted archive filtered by the participants
the key holds. Per-company quota (peppol_connections_per_company), the
shared PEPPOL_RECEIVING_MAX_REGISTRATIONS cap, and the global peppol rate
budget apply. Provider failures cross as CONNECTOR_UPSTREAM_ERROR with the
adapter's retryable flag (422 or 502).

Instance: lib/invoices/transports/connector.ts implements PeppolTransport
over that API and registers itself in connector mode (key present, no
QVALIA_* keys); getPeppolTransportAvailability() defaults to it when no
provider is selected, so an instance needs no PEPPOL_TRANSPORT_PROVIDER.
Webhooks are not brokered; the existing outbound status poll covers it.
Hosted is byte-identical: it has its own keys, so connector mode is never on.

Docs: SELF-HOSTING.md, SOVEREIGN.md, .env.example. Switch-on for third-party
instances stays gated on the Qvalia brokering-terms check; without the scope
every operation answers 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): authorize Peppol participants per key, harden the proxy after review

Review follow-ups on #2177. Authorization: a key may only register (and send
as) participant identifiers Arcim recorded on the key at issuance
(connector_keys.peppol_participants, migration 20260902191000) or the
licensee's own org number, and a document may only be submitted as a sender
the key has registered; X-Connector-Company stays an opaque per-company ref.
Cap: the shared access-point cap now counts fresh pending reservations and is
re-checked after this request's own reservation, so concurrent registrations
cannot both pass. Inbound: both halves of the participant id are filtered in
the archive query (over-fetched, then exact-pair checked), so foreign rows
sharing an identifier cannot consume the limit. Delete: deregistration is a
required transport capability, checked before the ledger row is revoked, and
registration refuses an access point that cannot deregister. Instance
transport: the hosted URL must be https (loopback http only, same rule as
getConnectorConfig), and the response body is read inside the timeout window
with body-read failures mapped to retryable transport errors.
issue-connector-key.ts gains --peppol-participants and
--peppol-connections-per-company. Declined: NOT VALID on the ledger CHECK
(the table is empty until keys are issued; the validated scan is instant).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* fix(connect): bind Peppol ownership to the instance company, query exact participant pairs

Second review round on #2177. Ownership is now (key, company_ref), not key
alone: a sender must be registered under the same company header, status and
evidence reads look the submission up under the header company, DELETE and
re-registration refuse a participant the key holds for another company, so
one company on a multi-company instance cannot act on another company's
registration through the shared key. The instance transport resolves the
owning company from its own peppol_deliveries / peppol_registrations rows
before status, evidence and deregistration calls (deps.companyFor,
deps.companyForParticipant, wired in transports/index.ts). Inbound listing
stays key-wide (the instance routes documents to its own companies by its
own registrations). The archive query now runs one exact-pair query per
scheme (scheme fixed, that scheme's identifiers), so neither foreign nor
cross-pair rows can consume the limit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:57:57 +02:00

280 lines
11 KiB
TypeScript

/**
* Peppol receiving: register a company's participant identifier at the
* contracted Access Point so other parties can send e-invoices to it.
*
* Provider-neutral: the transport does the SMP work, this module keeps the
* company-side record (`peppol_registrations`) truthful about it.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import {
PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID,
PEPPOL_BIS_BILLING_PROFILE_ID,
} from '@/lib/invoices/peppol-bis-billing'
import {
isPeppolTransportError,
type PeppolBusinessCard,
type PeppolDocumentTypeRegistration,
type PeppolParticipant,
type PeppolTransport,
} from '@/lib/invoices/peppol-transport'
import type { CompanySettings } from '@/types'
export const PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID =
'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
/** What a receiving company advertises: BIS Billing 3 invoices and credit notes. */
export const PEPPOL_RECEIVING_DOCUMENT_TYPES: PeppolDocumentTypeRegistration[] = [
{ processId: PEPPOL_BIS_BILLING_PROFILE_ID, documentTypeId: PEPPOL_BIS_BILLING_INVOICE_DOCUMENT_TYPE_ID },
{ processId: PEPPOL_BIS_BILLING_PROFILE_ID, documentTypeId: PEPPOL_BIS_BILLING_CREDIT_NOTE_DOCUMENT_TYPE_ID },
]
export type PeppolRegistrationStatus = 'pending' | 'registered' | 'failed' | 'deregistered'
export interface PeppolRegistrationRow {
id: string
company_id: string
user_id: string | null
provider: string
provider_account_reference: string | null
participant_scheme: string
participant_identifier: string
status: PeppolRegistrationStatus
business_card: Record<string, unknown>
document_types: unknown[]
registered_at: string | null
deregistered_at: string | null
last_error: string | null
created_at: string
updated_at: string
}
export type PeppolParticipantPreparation =
| { ok: true; participant: PeppolParticipant; businessCard: PeppolBusinessCard }
| {
ok: false
code:
| 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED'
| 'PEPPOL_REGISTRATION_PERSONAL_NUMBER'
| 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED'
}
type ParticipantSettings = Pick<
CompanySettings,
'org_number' | 'company_name' | 'vat_number' | 'city' | 'country'
>
/**
* Derive the participant (scheme 0007 + organisation number) and the Peppol
* Directory business card from the company settings. Personnummer-based
* identifiers are refused: publishing one would put personal identity data in
* a public directory; they need a separately configured 0088 GLN.
*/
export function preparePeppolParticipant(settings: ParticipantSettings): PeppolParticipantPreparation {
const digits = (settings.org_number ?? '').replace(/\D/g, '')
const orgNumber = digits.length === 12 && digits.startsWith('16') ? digits.slice(2) : digits
if (orgNumber.length !== 10) return { ok: false, code: 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED' }
// Same rule as the BIS Billing generator: an organisation number has its
// third digit >= 2; a personnummer has a month (01-12) there.
if (Number(orgNumber[2]) < 2) return { ok: false, code: 'PEPPOL_REGISTRATION_PERSONAL_NUMBER' }
const companyName = settings.company_name?.trim()
if (!companyName) return { ok: false, code: 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED' }
return {
ok: true,
participant: { scheme: '0007', identifier: orgNumber },
businessCard: {
companyName,
countryCode: (settings.country || 'SE').toUpperCase().slice(0, 2),
geographicalInformation: settings.city?.trim() || null,
vatNumber: settings.vat_number?.replace(/\s/g, '') || null,
orgNumber,
},
}
}
const LIVE_STATUSES: PeppolRegistrationStatus[] = ['pending', 'registered']
/**
* How many companies may publish a receiving identifier through our provider
* account. The Qvalia partner contract is priced per tenant (10 to start), so
* the product refuses the eleventh instead of silently exceeding the contract.
* Unset or invalid means no cap (self-hosted with an own provider account).
*/
export function getPeppolReceivingCap(env: Record<string, string | undefined> = process.env): number | null {
const raw = env.PEPPOL_RECEIVING_MAX_REGISTRATIONS?.trim()
if (!raw) return null
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
}
export async function countLivePeppolRegistrations(args: {
supabase: SupabaseClient
provider: string
}): Promise<number> {
const { count, error } = await args.supabase
.from('peppol_registrations')
.select('id', { count: 'exact', head: true })
.eq('provider', args.provider)
.in('status', LIVE_STATUSES)
if (error) throw new Error(`Failed to count Peppol registrations: ${error.message}`)
return count ?? 0
}
/** The live registration for a company at a provider, else the most recent history row. */
export async function getPeppolRegistration(args: {
supabase: SupabaseClient
companyId: string
provider: string
}): Promise<PeppolRegistrationRow | null> {
const { data, error } = await args.supabase
.from('peppol_registrations')
.select('*')
.eq('company_id', args.companyId)
.eq('provider', args.provider)
.order('updated_at', { ascending: false })
.limit(10)
if (error) throw new Error(`Failed to read Peppol registration: ${error.message}`)
const rows = (data ?? []) as PeppolRegistrationRow[]
return rows.find((row) => LIVE_STATUSES.includes(row.status)) ?? rows[0] ?? null
}
export type RegisterPeppolResult =
| { ok: true; registration: PeppolRegistrationRow }
| {
ok: false
code:
| 'PEPPOL_REGISTRATION_ORG_NUMBER_REQUIRED'
| 'PEPPOL_REGISTRATION_PERSONAL_NUMBER'
| 'PEPPOL_REGISTRATION_COMPANY_NAME_REQUIRED'
| 'PEPPOL_RECEIVING_UNSUPPORTED'
| 'PEPPOL_REGISTRATION_CAP_REACHED'
}
| { ok: false; code: 'PEPPOL_REGISTRATION_FAILED'; detail: string | null }
/**
* Publish the company's identifier through the transport and record the
* outcome. The row is written as `pending` before the network call and
* finalized after it, so a crash mid-way leaves a visible pending row rather
* than a silent gap.
*/
export async function registerCompanyForPeppolReceiving(args: {
service: SupabaseClient
companyId: string
userId: string
transport: PeppolTransport
settings: ParticipantSettings
}): Promise<RegisterPeppolResult> {
const { service, companyId, userId, transport } = args
if (!transport.registerRecipient) return { ok: false, code: 'PEPPOL_RECEIVING_UNSUPPORTED' }
const prepared = preparePeppolParticipant(args.settings)
if (!prepared.ok) return { ok: false, code: prepared.code }
const existing = await getPeppolRegistration({ supabase: service, companyId, provider: transport.provider })
const live = existing && LIVE_STATUSES.includes(existing.status) ? existing : null
let rowId: string
if (live) {
rowId = live.id
} else {
const cap = getPeppolReceivingCap()
if (cap !== null) {
const liveCount = await countLivePeppolRegistrations({ supabase: service, provider: transport.provider })
if (liveCount >= cap) return { ok: false, code: 'PEPPOL_REGISTRATION_CAP_REACHED' }
}
const { data, error } = await service
.from('peppol_registrations')
.insert({
company_id: companyId,
user_id: userId,
provider: transport.provider,
participant_scheme: prepared.participant.scheme,
participant_identifier: prepared.participant.identifier,
status: 'pending',
business_card: prepared.businessCard,
document_types: PEPPOL_RECEIVING_DOCUMENT_TYPES,
})
.select('id')
.single()
if (error || !data) throw new Error(`Failed to create Peppol registration: ${error?.message ?? 'no row'}`)
rowId = (data as { id: string }).id
}
try {
const result = await transport.registerRecipient({
participant: prepared.participant,
businessCard: prepared.businessCard,
documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES,
tenantReference: companyId,
})
const { data, error } = await service
.from('peppol_registrations')
.update({
status: 'registered',
registered_at: new Date().toISOString(),
deregistered_at: null,
provider_account_reference: result.providerAccountReference,
participant_scheme: prepared.participant.scheme,
participant_identifier: prepared.participant.identifier,
business_card: prepared.businessCard,
document_types: PEPPOL_RECEIVING_DOCUMENT_TYPES,
last_error: null,
})
.eq('id', rowId)
.select('*')
.single()
if (error || !data) throw new Error(`Failed to finalize Peppol registration: ${error?.message ?? 'no row'}`)
return { ok: true, registration: data as PeppolRegistrationRow }
} catch (err) {
const detail = isPeppolTransportError(err)
? [err.message, err.detail].filter(Boolean).join(': ').slice(0, 500)
: err instanceof Error ? err.message.slice(0, 500) : 'unknown error'
await service
.from('peppol_registrations')
.update({ status: 'failed', last_error: detail })
.eq('id', rowId)
return { ok: false, code: 'PEPPOL_REGISTRATION_FAILED', detail: isPeppolTransportError(err) ? err.detail : null }
}
}
export type DeregisterPeppolResult =
| { ok: true; registration: PeppolRegistrationRow }
| { ok: false; code: 'PEPPOL_RECEIVING_UNSUPPORTED' | 'PEPPOL_REGISTRATION_NOT_FOUND' }
| { ok: false; code: 'PEPPOL_REGISTRATION_FAILED'; detail: string | null }
export async function deregisterCompanyFromPeppolReceiving(args: {
service: SupabaseClient
companyId: string
transport: PeppolTransport
}): Promise<DeregisterPeppolResult> {
const { service, companyId, transport } = args
if (!transport.unregisterRecipient) return { ok: false, code: 'PEPPOL_RECEIVING_UNSUPPORTED' }
const existing = await getPeppolRegistration({ supabase: service, companyId, provider: transport.provider })
if (!existing || !LIVE_STATUSES.includes(existing.status)) {
return { ok: false, code: 'PEPPOL_REGISTRATION_NOT_FOUND' }
}
try {
await transport.unregisterRecipient({
scheme: existing.participant_scheme,
identifier: existing.participant_identifier,
})
} catch (err) {
const detail = isPeppolTransportError(err) ? err.detail : null
await service
.from('peppol_registrations')
.update({ last_error: err instanceof Error ? err.message.slice(0, 500) : 'unknown error' })
.eq('id', existing.id)
return { ok: false, code: 'PEPPOL_REGISTRATION_FAILED', detail }
}
const { data, error } = await service
.from('peppol_registrations')
.update({ status: 'deregistered', deregistered_at: new Date().toISOString(), last_error: null })
.eq('id', existing.id)
.select('*')
.single()
if (error || !data) throw new Error(`Failed to record Peppol deregistration: ${error?.message ?? 'no row'}`)
return { ok: true, registration: data as PeppolRegistrationRow }
}