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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
fcfa1ba974
commit
f31eeaa603
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { PeppolTransportError } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
let currentKey = {
|
||||
id: 'key-1',
|
||||
orgNumber: '5561234567',
|
||||
instanceUrl: 'https://bokforing.example.se',
|
||||
scopes: ['peppol'],
|
||||
status: 'active' as const,
|
||||
currentPeriodEnd: null as string | null,
|
||||
limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 0 },
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
budget: vi.fn(),
|
||||
ledger: {
|
||||
countHeldConnections: vi.fn(),
|
||||
deletePendingConnectionById: vi.fn(),
|
||||
createPendingConnection: vi.fn(),
|
||||
activateByPendingState: vi.fn(),
|
||||
findByAccountUid: vi.fn(),
|
||||
revokeByHandle: vi.fn(),
|
||||
touchConnection: vi.fn(),
|
||||
},
|
||||
peppolLedger: {
|
||||
countConnectorPeppolRegistrations: vi.fn(),
|
||||
findOwnedPeppolSubmission: vi.fn(),
|
||||
getPeppolAllowedIdentifiers: vi.fn(),
|
||||
isHostedPeppolParticipantLive: vi.fn(),
|
||||
isPeppolParticipantHeld: vi.fn(),
|
||||
listActivePeppolParticipants: vi.fn(),
|
||||
recordPeppolSubmission: vi.fn(),
|
||||
},
|
||||
registration: { countLivePeppolRegistrations: vi.fn(), getPeppolReceivingCap: vi.fn() },
|
||||
transport: {
|
||||
provider: 'qvalia',
|
||||
lookupRecipient: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
verifyWebhook: vi.fn(),
|
||||
retrieveEvidence: vi.fn(),
|
||||
pollDeliveryStatus: vi.fn(),
|
||||
registerRecipient: vi.fn(),
|
||||
unregisterRecipient: vi.fn(),
|
||||
listInboundDocuments: vi.fn(),
|
||||
fetchInboundDocumentXml: vi.fn(),
|
||||
},
|
||||
qvaliaConfigured: { value: true },
|
||||
archive: { rows: [] as unknown[], single: null as unknown, ins: [] as Array<[string, unknown]> },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
|
||||
withConnectorAuth: (_op: string, handler: (req: Request, ctx: unknown) => Promise<Response>) => (req: Request) =>
|
||||
handler(req, {
|
||||
requestId: 'conn_test',
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
supabase: {
|
||||
from: (table: string) => {
|
||||
if (table !== 'peppol_inbound_documents') throw new Error(`unexpected table ${table}`)
|
||||
const chain: Record<string, unknown> = {}
|
||||
const self = () => chain
|
||||
for (const m of ['select', 'eq', 'order']) chain[m] = self
|
||||
chain.in = (col: string, vals: unknown) => { h.archive.ins.push([col, vals]); return chain }
|
||||
chain.limit = () => Promise.resolve({ data: h.archive.rows, error: null })
|
||||
chain.maybeSingle = () => Promise.resolve({ data: h.archive.single, error: null })
|
||||
return chain
|
||||
},
|
||||
},
|
||||
key: currentKey,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => h.budget(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => ({ ...h.ledger, hashHandle: (s: string) => `h:${s}` }))
|
||||
vi.mock('@/lib/connect/hosted/peppol-ledger', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/connect/hosted/peppol-ledger')>('@/lib/connect/hosted/peppol-ledger')
|
||||
return {
|
||||
...h.peppolLedger,
|
||||
peppolHandle: actual.peppolHandle,
|
||||
parsePeppolHandle: actual.parsePeppolHandle,
|
||||
describePeppolUpstreamFailure: actual.describePeppolUpstreamFailure,
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/invoices/peppol-registration', () => h.registration)
|
||||
vi.mock('@/lib/invoices/transports/qvalia', () => ({
|
||||
QVALIA_PROVIDER: 'qvalia',
|
||||
readQvaliaConfigFromEnv: () => (h.qvaliaConfigured.value ? { apiKey: 'k' } : null),
|
||||
createQvaliaTransport: () => h.transport,
|
||||
}))
|
||||
|
||||
import { POST, PUT, DELETE } from '../route'
|
||||
|
||||
const participant = { scheme: '0007', identifier: '5561234567' }
|
||||
const businessCard = { companyName: 'Testbolaget AB', countryCode: 'SE', orgNumber: '5561234567' }
|
||||
const documentTypes = [{ processId: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0', documentTypeId: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice' }]
|
||||
|
||||
function req(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Request {
|
||||
return new Request(`https://app.gnubok.se/api/connect/peppol${path}`, {
|
||||
method,
|
||||
headers: { 'x-connector-company': 'company-1', 'content-type': 'application/json', ...headers },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentKey = { ...currentKey, scopes: ['peppol'], limits: { ...currentKey.limits, peppol_connections_per_company: 1 } }
|
||||
h.budget.mockResolvedValue({ ok: true })
|
||||
h.qvaliaConfigured.value = true
|
||||
h.archive.rows = []
|
||||
h.archive.single = null
|
||||
h.archive.ins = []
|
||||
h.ledger.findByAccountUid.mockResolvedValue(null)
|
||||
h.ledger.countHeldConnections.mockResolvedValue(0)
|
||||
h.ledger.createPendingConnection.mockResolvedValue('pending-1')
|
||||
h.ledger.activateByPendingState.mockResolvedValue({ id: 'row-1' })
|
||||
h.peppolLedger.isPeppolParticipantHeld.mockResolvedValue(false)
|
||||
h.peppolLedger.isHostedPeppolParticipantLive.mockResolvedValue(false)
|
||||
h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValue(0)
|
||||
h.peppolLedger.getPeppolAllowedIdentifiers.mockResolvedValue(new Set(['5561234567']))
|
||||
h.peppolLedger.listActivePeppolParticipants.mockResolvedValue([participant])
|
||||
h.registration.getPeppolReceivingCap.mockReturnValue(null)
|
||||
h.registration.countLivePeppolRegistrations.mockResolvedValue(0)
|
||||
})
|
||||
|
||||
describe('scope, configuration and path allowlist', () => {
|
||||
it('403s when the key lacks the peppol scope', async () => {
|
||||
currentKey = { ...currentKey, scopes: ['bank_sync'] }
|
||||
const res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SCOPE_MISSING')
|
||||
})
|
||||
|
||||
it('503s when the hosted access point is not configured', async () => {
|
||||
h.qvaliaConfigured.value = false
|
||||
const res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(503)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_UPSTREAM_UNCONFIGURED')
|
||||
})
|
||||
|
||||
it('refuses unknown operations', async () => {
|
||||
const res = await POST(req('POST', '/partner/123/anything', {}))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PATH_NOT_ALLOWED')
|
||||
})
|
||||
|
||||
it('400s on a malformed body', async () => {
|
||||
const res = await POST(req('POST', '/lookup', { participant: { scheme: 'abc', identifier: '' } }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(h.transport.lookupRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookup and submit', () => {
|
||||
it('forwards a lookup after reserving budget', async () => {
|
||||
h.transport.lookupRecipient.mockResolvedValue({ reachable: true, participant, capabilities: [], checkedAt: 'now' })
|
||||
const res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).reachable).toBe(true)
|
||||
expect(h.budget).toHaveBeenCalledWith(expect.anything(), 'peppol')
|
||||
})
|
||||
|
||||
it('429s with Retry-After when the global budget is exhausted', async () => {
|
||||
h.budget.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 17 })
|
||||
const res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('17')
|
||||
expect(h.transport.lookupRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const submissionBody = () => ({
|
||||
idempotencyKey: 'idem-1',
|
||||
tenantReference: 'company-OTHER',
|
||||
sender: participant,
|
||||
recipient: { scheme: '0007', identifier: '5569876543' },
|
||||
documentTypeId: documentTypes[0].documentTypeId,
|
||||
processId: documentTypes[0].processId,
|
||||
filename: 'inv.xml',
|
||||
contentType: 'application/xml',
|
||||
document: '<Invoice/>',
|
||||
documentSha256: 'a'.repeat(64),
|
||||
})
|
||||
|
||||
it('refuses to send as a participant this key has not registered, or registered for another company', async () => {
|
||||
h.ledger.findByAccountUid.mockResolvedValue(null)
|
||||
let res = await POST(req('POST', '/submit', submissionBody()))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED')
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-other', company_ref: 'company-2' })
|
||||
res = await POST(req('POST', '/submit', submissionBody()))
|
||||
expect(res.status).toBe(403)
|
||||
expect(h.transport.submit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits under the header company and records ownership of the provider submission id', async () => {
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-sender', company_ref: 'company-1' })
|
||||
h.transport.submit.mockResolvedValue({ provider: 'qvalia', providerSubmissionId: 'int-9', idempotencyKey: 'idem-1', tenantReference: 'company-1', acceptedAt: 'now' })
|
||||
const submission = {
|
||||
idempotencyKey: 'idem-1',
|
||||
tenantReference: 'company-OTHER',
|
||||
sender: participant,
|
||||
recipient: { scheme: '0007', identifier: '5569876543' },
|
||||
documentTypeId: documentTypes[0].documentTypeId,
|
||||
processId: documentTypes[0].processId,
|
||||
filename: 'inv.xml',
|
||||
contentType: 'application/xml',
|
||||
document: '<Invoice/>',
|
||||
documentSha256: 'a'.repeat(64),
|
||||
}
|
||||
const res = await POST(req('POST', '/submit', submission))
|
||||
expect(res.status).toBe(200)
|
||||
expect(h.transport.submit).toHaveBeenCalledWith(expect.objectContaining({ tenantReference: 'company-1' }))
|
||||
expect(h.peppolLedger.recordPeppolSubmission).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
||||
keyId: 'key-1', companyRef: 'company-1', providerSubmissionId: 'int-9', idempotencyKey: 'idem-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('requires the company header for submit', async () => {
|
||||
const res = await POST(new Request('https://app.gnubok.se/api/connect/peppol/submit', { method: 'POST', body: '{}' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_COMPANY_MISSING')
|
||||
})
|
||||
|
||||
it('maps a non-retryable provider rejection to 422 and a retryable one to 502', async () => {
|
||||
h.transport.lookupRecipient.mockRejectedValueOnce(new PeppolTransportError('rejected', { retryable: false, detail: 'bad id' }))
|
||||
let res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(422)
|
||||
expect(await res.json()).toMatchObject({ code: 'CONNECTOR_UPSTREAM_ERROR', retryable: false, detail: 'bad id' })
|
||||
h.transport.lookupRecipient.mockRejectedValueOnce(new PeppolTransportError('down', { retryable: true }))
|
||||
res = await POST(req('POST', '/lookup', { participant }))
|
||||
expect(res.status).toBe(502)
|
||||
expect((await res.json()).retryable).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status and evidence are ownership-gated', () => {
|
||||
it('404s for a submission this key did not make, and looks it up under the header company', async () => {
|
||||
h.peppolLedger.findOwnedPeppolSubmission.mockResolvedValue(null)
|
||||
const res = await POST(req('POST', '/status', { providerSubmissionId: 'int-foreign' }))
|
||||
expect(res.status).toBe(404)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_NOT_OWNED')
|
||||
expect(h.peppolLedger.findOwnedPeppolSubmission).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ keyId: 'key-1', companyRef: 'company-1', providerSubmissionId: 'int-foreign' }))
|
||||
expect(h.transport.pollDeliveryStatus).not.toHaveBeenCalled()
|
||||
const noCompany = await POST(new Request('https://app.gnubok.se/api/connect/peppol/status', { method: 'POST', body: JSON.stringify({ providerSubmissionId: 'int-9' }) }))
|
||||
expect(noCompany.status).toBe(400)
|
||||
})
|
||||
|
||||
it('polls and retrieves evidence for an owned submission', async () => {
|
||||
h.peppolLedger.findOwnedPeppolSubmission.mockResolvedValue({ id: 's1', company_ref: 'company-1' })
|
||||
h.transport.pollDeliveryStatus.mockResolvedValue([{ eventCode: 'status_poll' }])
|
||||
h.transport.retrieveEvidence.mockResolvedValue([{ evidenceType: 'qvalia_message_record' }])
|
||||
const status = await POST(req('POST', '/status', { providerSubmissionId: 'int-9' }))
|
||||
expect(await status.json()).toEqual([{ eventCode: 'status_poll' }])
|
||||
const evidence = await POST(req('POST', '/evidence', { providerSubmissionId: 'int-9' }))
|
||||
expect(await evidence.json()).toEqual([{ evidenceType: 'qvalia_message_record' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('receiving registration', () => {
|
||||
const body = { participant, businessCard, documentTypes }
|
||||
|
||||
it('registers a new participant: reserves quota, calls the access point, activates the ledger row', async () => {
|
||||
h.transport.registerRecipient.mockResolvedValue({ status: 'registered', participant, providerAccountReference: '5560000000', raw: { secret: 'x' } })
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json).toEqual({ status: 'registered', participant, providerAccountReference: 'accounted-connector', raw: {} })
|
||||
expect(h.ledger.createPendingConnection).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ service: 'peppol', companyRef: 'company-1', provider: 'qvalia' }))
|
||||
expect(h.ledger.activateByPendingState).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ handle: '0007:5561234567', accountUids: ['0007:5561234567'] }))
|
||||
expect(h.transport.registerRecipient).toHaveBeenCalledWith(expect.objectContaining({ participant }))
|
||||
})
|
||||
|
||||
it('refuses a participant another key already holds, before touching the access point', async () => {
|
||||
h.peppolLedger.isPeppolParticipantHeld.mockResolvedValue(true)
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(409)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_PARTICIPANT_TAKEN')
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a participant a hosted company holds', async () => {
|
||||
h.peppolLedger.isHostedPeppolParticipantLive.mockResolvedValue(true)
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(409)
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enforces the per-company quota with a reservation re-count', async () => {
|
||||
h.ledger.countHeldConnections.mockResolvedValueOnce(0).mockResolvedValueOnce(2)
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_QUOTA_EXCEEDED')
|
||||
expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1')
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares the provider-account cap between hosted companies and connector instances', async () => {
|
||||
h.registration.getPeppolReceivingCap.mockReturnValue(10)
|
||||
h.registration.countLivePeppolRegistrations.mockResolvedValue(7)
|
||||
h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValue(3)
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('PEPPOL_REGISTRATION_CAP_REACHED')
|
||||
expect(h.ledger.createPendingConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-checks the cap after its own reservation and rolls back when a concurrent registration won', async () => {
|
||||
h.registration.getPeppolReceivingCap.mockReturnValue(10)
|
||||
h.registration.countLivePeppolRegistrations.mockResolvedValue(7)
|
||||
h.peppolLedger.countConnectorPeppolRegistrations.mockResolvedValueOnce(2).mockResolvedValueOnce(4)
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('PEPPOL_REGISTRATION_CAP_REACHED')
|
||||
expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1')
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a participant identifier the key is not authorized to publish', async () => {
|
||||
h.peppolLedger.getPeppolAllowedIdentifiers.mockResolvedValue(new Set(['5560000001']))
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED')
|
||||
expect(h.peppolLedger.isPeppolParticipantHeld).not.toHaveBeenCalled()
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to register through an access point that cannot deregister', async () => {
|
||||
const original = h.transport.unregisterRecipient
|
||||
;(h.transport as { unregisterRecipient?: unknown }).unregisterRecipient = undefined
|
||||
try {
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(422)
|
||||
expect((await res.json()).code).toBe('PEPPOL_RECEIVING_UNSUPPORTED')
|
||||
const del = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567'))
|
||||
expect(del.status).toBe(404)
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' })
|
||||
const del2 = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567'))
|
||||
expect(del2.status).toBe(422)
|
||||
expect(h.ledger.revokeByHandle).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
h.transport.unregisterRecipient = original
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls the reservation back when the access point rejects', async () => {
|
||||
h.transport.registerRecipient.mockRejectedValue(new PeppolTransportError('nope', { retryable: false }))
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(422)
|
||||
expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1')
|
||||
})
|
||||
|
||||
it('unregisters upstream and answers 409 when activation loses the participant race', async () => {
|
||||
h.transport.registerRecipient.mockResolvedValue({ status: 'registered', participant, providerAccountReference: 'x', raw: {} })
|
||||
h.ledger.activateByPendingState.mockRejectedValue(new Error('duplicate key value violates unique constraint "idx_connector_connections_handle"'))
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(409)
|
||||
expect(h.transport.unregisterRecipient).toHaveBeenCalledWith(participant)
|
||||
expect(h.ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'pending-1')
|
||||
})
|
||||
|
||||
it('refuses to touch a participant this key holds for another company', async () => {
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-2', company_ref: 'company-2' })
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(409)
|
||||
expect(h.transport.registerRecipient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-registers an owned participant without consuming quota', async () => {
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' })
|
||||
h.transport.registerRecipient.mockResolvedValue({ status: 'updated', participant, providerAccountReference: 'x', raw: {} })
|
||||
const res = await PUT(req('PUT', '/recipient', body))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await res.json()).status).toBe('updated')
|
||||
expect(h.ledger.createPendingConnection).not.toHaveBeenCalled()
|
||||
expect(h.ledger.touchConnection).toHaveBeenCalledWith(expect.anything(), 'row-1')
|
||||
})
|
||||
|
||||
it('DELETE unregisters only an owned participant of the header company and revokes the ledger row', async () => {
|
||||
let res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567'))
|
||||
expect(res.status).toBe(404)
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-2', company_ref: 'company-2' })
|
||||
res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567'))
|
||||
expect(res.status).toBe(404)
|
||||
expect(h.transport.unregisterRecipient).not.toHaveBeenCalled()
|
||||
h.ledger.findByAccountUid.mockResolvedValue({ id: 'row-1', company_ref: 'company-1' })
|
||||
res = await DELETE(req('DELETE', '/recipient?scheme=0007&identifier=5561234567'))
|
||||
expect(res.status).toBe(204)
|
||||
expect(h.transport.unregisterRecipient).toHaveBeenCalledWith(participant)
|
||||
expect(h.ledger.revokeByHandle).toHaveBeenCalledWith(expect.anything(), { keyId: 'key-1', service: 'peppol', handle: '0007:5561234567' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('inbound documents come from the hosted archive, scoped to owned participants', () => {
|
||||
it('lists only documents addressed to a participant this key holds', async () => {
|
||||
h.archive.rows = [
|
||||
{ provider_document_id: 'doc-1', document_type: 'Invoice', ubl_json: { a: 1 }, received_at: 't1', recipient_scheme: '0007', recipient_identifier: '5561234567' },
|
||||
{ provider_document_id: 'doc-2', document_type: 'Invoice', ubl_json: { b: 2 }, received_at: 't2', recipient_scheme: '0088', recipient_identifier: '5561234567' },
|
||||
]
|
||||
const res = await POST(req('POST', '/inbound/list', { documentType: 'Invoice', limit: 10 }))
|
||||
expect(res.status).toBe(200)
|
||||
const items = await res.json()
|
||||
expect(items).toEqual([{ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: { a: 1 }, receivedAt: 't1' }])
|
||||
// One query per scheme with that scheme's identifiers: exact pairs, so
|
||||
// neither foreign nor cross-pair rows can consume the limit.
|
||||
expect(h.archive.ins).toEqual([['recipient_identifier', ['5561234567']]])
|
||||
expect(h.transport.listInboundDocuments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers an empty list without touching the archive when the key holds no participant', async () => {
|
||||
h.peppolLedger.listActivePeppolParticipants.mockResolvedValue([])
|
||||
const res = await POST(req('POST', '/inbound/list', { documentType: 'Invoice' }))
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('serves the archived XML for an owned document and 404s otherwise', async () => {
|
||||
h.archive.single = { xml_payload: '<Invoice/>', recipient_scheme: '0007', recipient_identifier: '5561234567' }
|
||||
let res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'Invoice' }))
|
||||
expect(await res.json()).toEqual({ xml: '<Invoice/>' })
|
||||
h.archive.single = { xml_payload: '<Invoice/>', recipient_scheme: '0007', recipient_identifier: '5569999999' }
|
||||
res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'Invoice' }))
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('fetches the XML live when the archive only holds JSON', async () => {
|
||||
h.archive.single = { xml_payload: null, recipient_scheme: '0007', recipient_identifier: '5561234567' }
|
||||
h.transport.fetchInboundDocumentXml.mockResolvedValue('<CreditNote/>')
|
||||
const res = await POST(req('POST', '/inbound/xml', { providerDocumentId: 'doc-1', documentType: 'CreditNote' }))
|
||||
expect(await res.json()).toEqual({ xml: '<CreditNote/>' })
|
||||
expect(h.transport.fetchInboundDocumentXml).toHaveBeenCalledWith('doc-1', 'CreditNote')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,562 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import {
|
||||
activateByPendingState,
|
||||
countHeldConnections,
|
||||
createPendingConnection,
|
||||
deletePendingConnectionById,
|
||||
findByAccountUid,
|
||||
revokeByHandle,
|
||||
touchConnection,
|
||||
} from '@/lib/connect/hosted/ledger'
|
||||
import {
|
||||
countConnectorPeppolRegistrations,
|
||||
describePeppolUpstreamFailure,
|
||||
findOwnedPeppolSubmission,
|
||||
getPeppolAllowedIdentifiers,
|
||||
isHostedPeppolParticipantLive,
|
||||
isPeppolParticipantHeld,
|
||||
listActivePeppolParticipants,
|
||||
peppolHandle,
|
||||
recordPeppolSubmission,
|
||||
} from '@/lib/connect/hosted/peppol-ledger'
|
||||
import type { PeppolInboundMessage, PeppolTransport } from '@/lib/invoices/peppol-transport'
|
||||
import { countLivePeppolRegistrations, getPeppolReceivingCap } from '@/lib/invoices/peppol-registration'
|
||||
import { QVALIA_PROVIDER, createQvaliaTransport, readQvaliaConfigFromEnv } from '@/lib/invoices/transports/qvalia'
|
||||
|
||||
/**
|
||||
* Peppol proxy for self-hosted instances (WS3, Peppol upstream).
|
||||
*
|
||||
* Unlike the bank proxy this is not a path passthrough: the instance speaks
|
||||
* the PeppolTransport operations and the hosted side talks to Qvalia with
|
||||
* Arcim's partner keys. Reasons: Qvalia URLs embed Arcim's partner and
|
||||
* account numbers, the account is shared by every hosted company and every
|
||||
* instance (so reads must be scoped to what the caller owns), and the
|
||||
* inbound "read" endpoint is destructive (it marks documents read for the
|
||||
* whole account, which the hosted inbound cron already does).
|
||||
*
|
||||
* Ownership model:
|
||||
* - a participant may only be registered when its identifier is on the
|
||||
* key's allowlist (connector_keys.peppol_participants, recorded by Arcim
|
||||
* at issuance) or is the licensee's own org number: the hosted side has
|
||||
* no other way to know which organisations an instance legitimately
|
||||
* hosts, and X-Connector-Company is caller-supplied;
|
||||
* - a receiving registration is a ledger row (service 'peppol') whose
|
||||
* account_uids holds the participant id; one participant, one key;
|
||||
* - a document may only be SENT as a participant the key has registered
|
||||
* (the registration is the identity claim, the allowlist authorizes it);
|
||||
* - registrations, submissions, status polls, evidence reads and
|
||||
* deregistration are bound to the (key, company_ref) pair, so one company
|
||||
* on a multi-company instance cannot act on another company's
|
||||
* registration through the shared key; inbound listing is key-wide
|
||||
* because the instance's inbound sync routes documents to its own
|
||||
* companies by its own registrations;
|
||||
* - inbound documents are served from the hosted archive
|
||||
* (peppol_inbound_documents, filled by /api/peppol/inbound/cron) filtered
|
||||
* by the participants this key holds, never by calling Qvalia's read
|
||||
* endpoint on the instance's behalf.
|
||||
*
|
||||
* Switch-on for third-party instances is gated on the Qvalia brokering-terms
|
||||
* check (see the migration note): without the `peppol` scope on the key every
|
||||
* operation answers 403.
|
||||
*/
|
||||
|
||||
const MAX_DOCUMENT_CHARS = 5_000_000
|
||||
const COMPANY_HEADER = 'x-connector-company'
|
||||
const PENDING_STATE_PREFIX = 'peppol:'
|
||||
|
||||
const participantSchema = z.object({
|
||||
// Peppol participant scheme (ISO 6523 ICD), four digits; not a BAS account.
|
||||
scheme: z.string().length(4).regex(/^\d+$/),
|
||||
identifier: z.string().trim().min(1).max(64),
|
||||
})
|
||||
const documentTypeSchema = z.enum(['Invoice', 'CreditNote'])
|
||||
|
||||
const lookupSchema = z.object({ participant: participantSchema })
|
||||
const submissionSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(128),
|
||||
tenantReference: z.string().trim().min(1).max(128),
|
||||
sender: participantSchema,
|
||||
recipient: participantSchema,
|
||||
documentTypeId: z.string().trim().min(1).max(512),
|
||||
processId: z.string().trim().min(1).max(512),
|
||||
filename: z.string().trim().min(1).max(255),
|
||||
contentType: z.literal('application/xml'),
|
||||
document: z.string().min(1).max(MAX_DOCUMENT_CHARS),
|
||||
documentSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
const submissionRefSchema = z.object({ providerSubmissionId: z.string().trim().min(1).max(128) })
|
||||
const registrationSchema = z.object({
|
||||
participant: participantSchema,
|
||||
businessCard: z.object({
|
||||
companyName: z.string().trim().min(1).max(200),
|
||||
countryCode: z.string().trim().length(2),
|
||||
geographicalInformation: z.string().max(500).nullish(),
|
||||
vatNumber: z.string().max(64).nullish(),
|
||||
orgNumber: z.string().max(64).nullish(),
|
||||
}),
|
||||
documentTypes: z.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) })).min(1).max(20),
|
||||
description: z.string().max(200).nullish(),
|
||||
tenantReference: z.string().max(128).nullish(),
|
||||
})
|
||||
const inboundListSchema = z.object({
|
||||
documentType: documentTypeSchema,
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
includeRead: z.boolean().optional(),
|
||||
})
|
||||
const inboundXmlSchema = z.object({
|
||||
providerDocumentId: z.string().trim().min(1).max(128),
|
||||
documentType: documentTypeSchema,
|
||||
})
|
||||
|
||||
function hostedTransport(): PeppolTransport | null {
|
||||
const config = readQvaliaConfigFromEnv()
|
||||
return config ? createQvaliaTransport(config) : null
|
||||
}
|
||||
|
||||
function pathOf(request: Request): string {
|
||||
const idx = request.url.indexOf('/api/connect/peppol')
|
||||
const rest = idx === -1 ? '' : request.url.slice(idx + '/api/connect/peppol'.length)
|
||||
return rest.split('?')[0].replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function companyRef(request: Request): string | null {
|
||||
return request.headers.get(COMPANY_HEADER)?.trim() || null
|
||||
}
|
||||
|
||||
function requireScope(ctx: ConnectorContext): NextResponse | null {
|
||||
if (ctx.key.scopes.includes('peppol')) return null
|
||||
return NextResponse.json(
|
||||
{ error: 'This connector key does not include Peppol', code: 'CONNECTOR_SCOPE_MISSING' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
async function budgetOr429(ctx: ConnectorContext): Promise<NextResponse | null> {
|
||||
const budget = await reserveUpstream(ctx.supabase, 'peppol')
|
||||
if (budget.ok) return null
|
||||
ctx.log.warn('peppol connector budget exhausted', { scope: budget.scope })
|
||||
return NextResponse.json(
|
||||
{ error: 'Peppol connector is busy, try again shortly', code: 'CONNECTOR_RATE_LIMITED', scope: budget.scope },
|
||||
{ status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } },
|
||||
)
|
||||
}
|
||||
|
||||
async function parseBody<T>(request: Request, schema: z.ZodType<T>): Promise<{ ok: true; value: T } | { ok: false; response: NextResponse }> {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = await request.json()
|
||||
} catch {
|
||||
return { ok: false, response: NextResponse.json({ error: 'Invalid JSON', code: 'BAD_REQUEST' }, { status: 400 }) }
|
||||
}
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Invalid request body', code: 'BAD_REQUEST', detail: parsed.error.issues.slice(0, 5).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') },
|
||||
{ status: 400 },
|
||||
),
|
||||
}
|
||||
}
|
||||
return { ok: true, value: parsed.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider failure is answered with the transport's retryable flag so the
|
||||
* instance rethrows an equivalent PeppolTransportError. Anything else is a
|
||||
* hosted bug and falls through to the wrapper's 500.
|
||||
*/
|
||||
function upstreamFailure(err: unknown, ctx: ConnectorContext, op: string): NextResponse {
|
||||
const failure = describePeppolUpstreamFailure(err)
|
||||
if (!failure) throw err
|
||||
ctx.log.warn(`peppol upstream failed: ${op}`, { text: failure.text, retryable: failure.retryable })
|
||||
return NextResponse.json(
|
||||
{ error: failure.text, code: 'CONNECTOR_UPSTREAM_ERROR', retryable: failure.retryable, detail: failure.hint },
|
||||
{ status: failure.retryable ? 502 : 422 },
|
||||
)
|
||||
}
|
||||
|
||||
function unconfigured(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: 'Peppol access point is not configured on the hosted service', code: 'CONNECTOR_UPSTREAM_UNCONFIGURED', retryable: true },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
function notAllowed(): NextResponse {
|
||||
return NextResponse.json({ error: 'Not allowed', code: 'CONNECTOR_PATH_NOT_ALLOWED' }, { status: 403 })
|
||||
}
|
||||
function notOwned(): NextResponse {
|
||||
return NextResponse.json({ error: 'Unknown registration or submission for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 })
|
||||
}
|
||||
function missingCompany(): NextResponse {
|
||||
return NextResponse.json({ error: 'Missing X-Connector-Company header', code: 'CONNECTOR_COMPANY_MISSING' }, { status: 400 })
|
||||
}
|
||||
function participantTaken(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: 'That Peppol participant is already registered through another account', code: 'CONNECTOR_PEPPOL_PARTICIPANT_TAKEN', retryable: false },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return /23505|idx_connector_connections_handle|duplicate key/i.test(message)
|
||||
}
|
||||
|
||||
export const POST = withConnectorAuth('connect.peppol', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const path = pathOf(request)
|
||||
const transport = hostedTransport()
|
||||
if (!transport) return unconfigured()
|
||||
|
||||
if (path === '/lookup') {
|
||||
const body = await parseBody(request, lookupSchema)
|
||||
if (!body.ok) return body.response
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
try {
|
||||
return NextResponse.json(await transport.lookupRecipient(body.value.participant))
|
||||
} catch (err) {
|
||||
return upstreamFailure(err, ctx, 'lookup')
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/submit') {
|
||||
const cref = companyRef(request)
|
||||
if (!cref) return missingCompany()
|
||||
const body = await parseBody(request, submissionSchema)
|
||||
if (!body.ok) return body.response
|
||||
// Sending as a participant is an identity claim: only participants this
|
||||
// key registered (which the allowlist authorized) may appear as sender.
|
||||
const senderOwned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: peppolHandle(body.value.sender) })
|
||||
if (!senderOwned || senderOwned.company_ref !== cref) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The sender participant is not registered through this connector key', code: 'CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED', retryable: false },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
// The tenant reference the instance signs its delivery with must be the
|
||||
// company the request is scoped to; otherwise a key could stage under
|
||||
// one company and record ownership under another.
|
||||
const submission = { ...body.value, tenantReference: cref }
|
||||
let receipt
|
||||
try {
|
||||
receipt = await transport.submit(submission)
|
||||
} catch (err) {
|
||||
return upstreamFailure(err, ctx, 'submit')
|
||||
}
|
||||
await recordPeppolSubmission(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
companyRef: cref,
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerSubmissionId: receipt.providerSubmissionId,
|
||||
idempotencyKey: submission.idempotencyKey,
|
||||
})
|
||||
return NextResponse.json(receipt)
|
||||
}
|
||||
|
||||
if (path === '/status' || path === '/evidence') {
|
||||
const cref = companyRef(request)
|
||||
if (!cref) return missingCompany()
|
||||
const body = await parseBody(request, submissionRefSchema)
|
||||
if (!body.ok) return body.response
|
||||
const owned = await findOwnedPeppolSubmission(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
companyRef: cref,
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerSubmissionId: body.value.providerSubmissionId,
|
||||
})
|
||||
if (!owned) return notOwned()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
try {
|
||||
if (path === '/status') {
|
||||
const events = transport.pollDeliveryStatus
|
||||
? await transport.pollDeliveryStatus(body.value.providerSubmissionId)
|
||||
: []
|
||||
return NextResponse.json(events)
|
||||
}
|
||||
return NextResponse.json(await transport.retrieveEvidence(body.value.providerSubmissionId))
|
||||
} catch (err) {
|
||||
return upstreamFailure(err, ctx, path.slice(1))
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/inbound/list') {
|
||||
const body = await parseBody(request, inboundListSchema)
|
||||
if (!body.ok) return body.response
|
||||
const participants = await listActivePeppolParticipants(ctx.supabase, ctx.key.id)
|
||||
if (participants.length === 0) return NextResponse.json([])
|
||||
const limit = Math.min(Math.max(body.value.limit ?? 25, 1), 100)
|
||||
// Exact (scheme, identifier) pairs, one query per scheme: within a scheme
|
||||
// the identifier list IS the pair set, so no foreign or cross-pair row can
|
||||
// consume the limit. Schemes are one or two in practice (0007, 0088).
|
||||
const identifiersByScheme = new Map<string, Set<string>>()
|
||||
for (const p of participants) {
|
||||
const set = identifiersByScheme.get(p.scheme) ?? new Set<string>()
|
||||
set.add(p.identifier)
|
||||
identifiersByScheme.set(p.scheme, set)
|
||||
}
|
||||
type ArchiveRow = {
|
||||
provider_document_id: string
|
||||
document_type: 'Invoice' | 'CreditNote'
|
||||
ubl_json: Record<string, unknown>
|
||||
received_at: string | null
|
||||
recipient_scheme: string | null
|
||||
recipient_identifier: string | null
|
||||
}
|
||||
const rows: ArchiveRow[] = []
|
||||
for (const [scheme, identifiers] of identifiersByScheme) {
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('peppol_inbound_documents')
|
||||
.select('provider_document_id, document_type, ubl_json, received_at, recipient_scheme, recipient_identifier')
|
||||
.eq('provider', QVALIA_PROVIDER)
|
||||
.eq('document_type', body.value.documentType)
|
||||
.eq('recipient_scheme', scheme)
|
||||
.in('recipient_identifier', [...identifiers])
|
||||
.order('received_at', { ascending: false })
|
||||
.limit(limit)
|
||||
if (error) throw new Error(`inbound archive read failed: ${error.message}`)
|
||||
rows.push(...((data ?? []) as ArchiveRow[]))
|
||||
}
|
||||
rows.sort((a, b) => (b.received_at ?? '').localeCompare(a.received_at ?? ''))
|
||||
const owned = new Set(participants.map(peppolHandle))
|
||||
const messages: PeppolInboundMessage[] = []
|
||||
for (const row of rows) {
|
||||
if (!row.recipient_scheme || !row.recipient_identifier) continue
|
||||
if (!owned.has(peppolHandle({ scheme: row.recipient_scheme, identifier: row.recipient_identifier }))) continue
|
||||
messages.push({
|
||||
provider: QVALIA_PROVIDER,
|
||||
providerDocumentId: row.provider_document_id,
|
||||
documentType: row.document_type,
|
||||
payload: row.ubl_json ?? {},
|
||||
receivedAt: row.received_at,
|
||||
})
|
||||
if (messages.length >= limit) break
|
||||
}
|
||||
return NextResponse.json(messages)
|
||||
}
|
||||
|
||||
if (path === '/inbound/xml') {
|
||||
const body = await parseBody(request, inboundXmlSchema)
|
||||
if (!body.ok) return body.response
|
||||
const participants = await listActivePeppolParticipants(ctx.supabase, ctx.key.id)
|
||||
const owned = new Set(participants.map(peppolHandle))
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('peppol_inbound_documents')
|
||||
.select('xml_payload, recipient_scheme, recipient_identifier')
|
||||
.eq('provider', QVALIA_PROVIDER)
|
||||
.eq('provider_document_id', body.value.providerDocumentId)
|
||||
.eq('document_type', body.value.documentType)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`inbound archive read failed: ${error.message}`)
|
||||
const row = data as { xml_payload: string | null; recipient_scheme: string | null; recipient_identifier: string | null } | null
|
||||
if (!row || !row.recipient_scheme || !row.recipient_identifier) return notOwned()
|
||||
if (!owned.has(peppolHandle({ scheme: row.recipient_scheme, identifier: row.recipient_identifier }))) return notOwned()
|
||||
if (row.xml_payload) return NextResponse.json({ xml: row.xml_payload })
|
||||
// The archive kept JSON but the XML fetch failed at cron time: retry live.
|
||||
if (!transport.fetchInboundDocumentXml) return NextResponse.json({ xml: null })
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
try {
|
||||
return NextResponse.json({ xml: await transport.fetchInboundDocumentXml(body.value.providerDocumentId, body.value.documentType) })
|
||||
} catch (err) {
|
||||
return upstreamFailure(err, ctx, 'inbound.xml')
|
||||
}
|
||||
}
|
||||
|
||||
return notAllowed()
|
||||
})
|
||||
|
||||
export const PUT = withConnectorAuth('connect.peppol', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
if (pathOf(request) !== '/recipient') return notAllowed()
|
||||
const cref = companyRef(request)
|
||||
if (!cref) return missingCompany()
|
||||
const body = await parseBody(request, registrationSchema)
|
||||
if (!body.ok) return body.response
|
||||
const transport = hostedTransport()
|
||||
if (!transport) return unconfigured()
|
||||
// Both directions are required: a registration this route cannot undo
|
||||
// (rollback on a lost race, DELETE later) must never be created.
|
||||
if (!transport.registerRecipient || !transport.unregisterRecipient) {
|
||||
return NextResponse.json({ error: 'Receiving is not supported by the hosted access point', code: 'PEPPOL_RECEIVING_UNSUPPORTED', retryable: false }, { status: 422 })
|
||||
}
|
||||
const unregisterUpstream = transport.unregisterRecipient
|
||||
|
||||
const participant = { scheme: body.value.participant.scheme, identifier: body.value.participant.identifier.replace(/\s/g, '') }
|
||||
const handle = peppolHandle(participant)
|
||||
const owned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: handle })
|
||||
// Held by this key for ANOTHER company on the instance: not re-registrable
|
||||
// from here, and not claimable either (it is not free).
|
||||
if (owned && owned.company_ref !== cref) return participantTaken()
|
||||
|
||||
let pendingId: string | null = null
|
||||
let pendingState: string | null = null
|
||||
if (!owned) {
|
||||
const allowed = await getPeppolAllowedIdentifiers(ctx.supabase, ctx.key.id)
|
||||
if (!allowed.has(participant.identifier)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This connector key is not authorized to publish that participant', code: 'CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED', retryable: false },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
if (await isPeppolParticipantHeld(ctx.supabase, handle)) return participantTaken()
|
||||
if (await isHostedPeppolParticipantLive(ctx.supabase, { provider: QVALIA_PROVIDER, participant })) return participantTaken()
|
||||
|
||||
const limit = ctx.key.limits.peppol_connections_per_company
|
||||
const quotaExceeded = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Peppol registration quota reached for this company', code: 'CONNECTOR_QUOTA_EXCEEDED', limit, retryable: false },
|
||||
{ status: 403 },
|
||||
)
|
||||
const held = await countHeldConnections(ctx.supabase, ctx.key.id, 'peppol', cref)
|
||||
if (held >= limit) return quotaExceeded()
|
||||
|
||||
// The provider account is priced per registered tenant: hosted companies
|
||||
// and connector instances share that cap. Fresh pending reservations
|
||||
// count, and the cap is re-checked after this request's own reservation,
|
||||
// so concurrent registrations cannot both squeeze past it.
|
||||
const cap = getPeppolReceivingCap()
|
||||
const capReached = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'The access point has no free receiving slot right now', code: 'PEPPOL_REGISTRATION_CAP_REACHED', retryable: false },
|
||||
{ status: 403 },
|
||||
)
|
||||
let hostedLive = 0
|
||||
if (cap !== null) {
|
||||
const [hosted, connector] = await Promise.all([
|
||||
countLivePeppolRegistrations({ supabase: ctx.supabase, provider: QVALIA_PROVIDER }),
|
||||
countConnectorPeppolRegistrations(ctx.supabase),
|
||||
])
|
||||
hostedLive = hosted
|
||||
if (hosted + connector >= cap) return capReached()
|
||||
}
|
||||
|
||||
pendingState = `${PENDING_STATE_PREFIX}${crypto.randomUUID()}`
|
||||
pendingId = await createPendingConnection(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
service: 'peppol',
|
||||
companyRef: cref,
|
||||
provider: QVALIA_PROVIDER,
|
||||
pendingState,
|
||||
})
|
||||
const heldAfter = await countHeldConnections(ctx.supabase, ctx.key.id, 'peppol', cref)
|
||||
if (heldAfter > limit) {
|
||||
await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return quotaExceeded()
|
||||
}
|
||||
if (cap !== null) {
|
||||
const connectorAfter = await countConnectorPeppolRegistrations(ctx.supabase)
|
||||
if (hostedLive + connectorAfter > cap) {
|
||||
await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return capReached()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) {
|
||||
if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return blocked
|
||||
}
|
||||
|
||||
let result
|
||||
try {
|
||||
result = await transport.registerRecipient({
|
||||
participant,
|
||||
businessCard: body.value.businessCard,
|
||||
documentTypes: body.value.documentTypes,
|
||||
description: body.value.description ?? null,
|
||||
})
|
||||
} catch (err) {
|
||||
if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return upstreamFailure(err, ctx, 'register')
|
||||
}
|
||||
|
||||
if (owned) {
|
||||
await touchConnection(ctx.supabase, owned.id)
|
||||
} else {
|
||||
let activated = null
|
||||
let activationError: unknown = null
|
||||
try {
|
||||
activated = await activateByPendingState(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
pendingState: pendingState as string,
|
||||
handle,
|
||||
accountUids: [handle],
|
||||
})
|
||||
} catch (err) {
|
||||
activationError = err
|
||||
}
|
||||
if (!activated) {
|
||||
// Lost a race for the participant (or the row vanished): the upstream
|
||||
// registration must not outlive its ledger row.
|
||||
if (pendingId) await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
try {
|
||||
await unregisterUpstream(participant)
|
||||
} catch (err) {
|
||||
ctx.log.error('could not roll back upstream peppol registration; participant is registered upstream without a ledger row', err as Error, { handle })
|
||||
}
|
||||
if (activationError && !isUniqueViolation(activationError)) {
|
||||
ctx.log.error('peppol ledger activation failed', activationError as Error)
|
||||
return NextResponse.json({ error: 'Could not record the registration', code: 'CONNECTOR_LEDGER_FAILED', retryable: true }, { status: 502 })
|
||||
}
|
||||
return participantTaken()
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: result.status,
|
||||
participant,
|
||||
// Arcim's provider account reference stays hosted-side.
|
||||
providerAccountReference: 'accounted-connector',
|
||||
raw: {},
|
||||
})
|
||||
})
|
||||
|
||||
export const DELETE = withConnectorAuth('connect.peppol', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
if (pathOf(request) !== '/recipient') return notAllowed()
|
||||
const url = new URL(request.url)
|
||||
const parsed = participantSchema.safeParse({
|
||||
scheme: url.searchParams.get('scheme') ?? '',
|
||||
identifier: url.searchParams.get('identifier') ?? '',
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'scheme and identifier query parameters are required', code: 'BAD_REQUEST' }, { status: 400 })
|
||||
}
|
||||
const cref = companyRef(request)
|
||||
if (!cref) return missingCompany()
|
||||
const participant = { scheme: parsed.data.scheme, identifier: parsed.data.identifier.replace(/\s/g, '') }
|
||||
const handle = peppolHandle(participant)
|
||||
const owned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: handle })
|
||||
if (!owned || owned.company_ref !== cref) return notOwned()
|
||||
const transport = hostedTransport()
|
||||
if (!transport) return unconfigured()
|
||||
if (!transport.unregisterRecipient) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Deregistration is not supported by the hosted access point', code: 'PEPPOL_RECEIVING_UNSUPPORTED', retryable: false },
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
try {
|
||||
await transport.unregisterRecipient(participant)
|
||||
} catch (err) {
|
||||
return upstreamFailure(err, ctx, 'unregister')
|
||||
}
|
||||
// Only after the upstream deregistration took: revoking first would leave a
|
||||
// participant receiving at the access point that no key owns.
|
||||
await revokeByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'peppol', handle })
|
||||
return new NextResponse(null, { status: 204 })
|
||||
})
|
||||
Reference in New Issue
Block a user