* feat(peppol): receive e-invoices via Qvalia: registration, inbound archive, inbox delivery Second Peppol slice (#546). Qvalia confirmed that sending needs no per-company account, so receiving keeps the consolidated partner account: each company publishes its 0007:orgnr on our account and inbound documents are routed by the AccountingCustomerParty endpoint. - PeppolTransport grows optional receiving methods (registerRecipient, unregisterRecipient, listInboundDocuments, fetchInboundDocumentXml); the Qvalia adapter implements them (PUT/DELETE /peppol/{id}, readinvoices / readcreditnotes, exact XML fetch). - lib/invoices/peppol-inbound-ubl.ts reads the provider's UBL-JSON (xml2js-style prefixed keys, verified against Qvalia's real inbound test invoice, kept as a fixture) into a neutral document: parties, payment means with SE:BANKGIRO/SE:PLUSGIRO/IBAN, totals, VAT subtotals, lines, embedded attachments, credit notes. - Migration 20260821170000: peppol_registrations (one live row per company and participant), peppol_inbound_documents (exact XML immutable and undeletable, routed once), invoice_inbox_items.source gains 'peppol' with a per-channel dedupe index; pg-real test covers RLS, uniqueness, immutability and routing. - POST/DELETE/GET /api/settings/peppol + "E-faktura via Peppol" switch in Settings > Fakturering; personnummer-based companies are refused until 0088 GLN exists; sandbox refused. - GET /api/peppol/inbound/cron every 10 minutes: archive, route, deliver. lib/invoices/peppol-inbox-delivery.ts archives the XML as a WORM document (upload_source e_invoice, extractionOwner none), an embedded PDF when present, and creates the inbox row with the extraction filled from the UBL (confidence 1, no model pass), matching the supplier by org number. The existing inbox review/convert flow takes over. - document-service accepts application/xml for the archive; inbox list shows a Peppol icon. Refs #546 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ * test(peppol): archive contract, pg fixture and phantom-column ceiling for the receiving tables The two new tables are räkenskapsinformation and join MASTER_DATA_DUMP_TABLES; the pg fixture for a deregistered row now carries deregistered_at as the status-shape constraint requires; the archive insert is an inline literal and the one generic processing-state updater is accounted for in the ceiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
247 lines
9.6 KiB
TypeScript
247 lines
9.6 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']
|
|
|
|
/** 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'
|
|
}
|
|
| { 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 { 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,
|
|
})
|
|
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 }
|
|
}
|