Files
accounted/app/api/settings/peppol/route.ts
T
Jakob Wennberg 3ac80edc96 feat(peppol): gate Peppol per company: request access, operator enables with a sending cap (#1794)
* feat(peppol): gate Peppol per company: request access, operator enables with a sending cap

Peppol is no longer available to every company by default. Each transmission
is billed per document by the access point and each receiving identifier
consumes a contracted tenant slot, so the product now works like this:

- peppol_access (new table, RLS read-only for members, service-role writes):
  status requested | enabled | disabled, max_sends (null = no cap),
  receive_enabled as a separate grant, who asked and who enabled.
- POST /api/settings/peppol/access: the company asks from Settings >
  Fakturering; the row is written and the operators are e-mailed (best effort,
  the row is the source of truth).
- scripts/peppol/access.ts list | enable <company|orgnr> [--max-sends N]
  [--receive] | disable | show: the operator side.
- POST /api/invoices/[id]/peppol/send refuses PEPPOL_ACCESS_REQUIRED /
  PEPPOL_SEND_LIMIT_REACHED before touching the invoice; the invoice page's
  send item says so instead of pretending. Registration for receiving refuses
  PEPPOL_ACCESS_REQUIRED / PEPPOL_RECEIVING_NOT_ENABLED.
- Settings UI: access status row with "Begär åtkomst", sends used of cap,
  receiving switch only once receiving is granted.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* test(peppol): pass route params to the settings handlers; baseline-align the access row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* fix(peppol): revoke default table privileges from authenticated on the access and receiving tables

Supabase grants ALL on new tables to authenticated by default; the earlier
REVOKE covered PUBLIC and anon only, so a member's UPDATE on peppol_access was
an RLS-filtered no-op instead of a permission error (pg-real caught it).

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>
2026-08-21 17:27:45 +02:00

153 lines
5.7 KiB
TypeScript

import { NextResponse } from 'next/server'
import { privateNoStore } from '@/lib/api/private-no-store'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { ensureInitialized } from '@/lib/init'
import { getPeppolAccess, getPeppolAccessSummary } from '@/lib/invoices/peppol-access'
import {
deregisterCompanyFromPeppolReceiving,
getPeppolRegistration,
registerCompanyForPeppolReceiving,
type PeppolRegistrationRow,
} from '@/lib/invoices/peppol-registration'
import {
getPeppolTransport,
getPeppolTransportAvailability,
type PeppolTransport,
} from '@/lib/invoices/peppol-transport'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import { createServiceClient } from '@/lib/supabase/server'
import type { CompanySettings } from '@/types'
ensureInitialized()
function registrationPayload(row: PeppolRegistrationRow | null) {
if (!row) return null
return {
id: row.id,
provider: row.provider,
participant_scheme: row.participant_scheme,
participant_identifier: row.participant_identifier,
status: row.status,
registered_at: row.registered_at,
deregistered_at: row.deregistered_at,
last_error: row.last_error,
updated_at: row.updated_at,
}
}
function resolveTransport(): { transport: PeppolTransport; provider: string } | null {
const availability = getPeppolTransportAvailability()
if (!availability.available) return null
const transport = getPeppolTransport(availability.provider)
return transport ? { transport, provider: availability.provider } : null
}
/** GET /api/settings/peppol: receiving status for the active company. */
export const GET = withRouteContext(
'settings.peppol.get',
async (_request, { supabase, companyId, log, requestId }) => {
const availability = getPeppolTransportAvailability()
const resolved = resolveTransport()
try {
const registration = resolved
? await getPeppolRegistration({ supabase, companyId, provider: resolved.provider })
: null
const access = await getPeppolAccessSummary({ supabase, service: createServiceClient(), companyId })
return privateNoStore(NextResponse.json({
data: {
transport: availability,
receiving_supported: !!resolved?.transport.registerRecipient,
access,
registration: registrationPayload(registration),
},
}))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
)
/** POST /api/settings/peppol: publish the company's Peppol identifier for receiving. */
export const POST = withRouteContext(
'settings.peppol.register',
async (_request, { supabase, companyId, user, log, requestId }) => {
const resolved = resolveTransport()
if (!resolved) {
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
}
if (await isSandboxCompany(supabase, companyId)) {
return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId }))
}
// Receiving consumes a contracted tenant slot: operators grant it per company.
const access = await getPeppolAccess(createServiceClient(), companyId)
if (!access || access.status !== 'enabled') {
return privateNoStore(errorResponseFromCode('PEPPOL_ACCESS_REQUIRED', log, { requestId }))
}
if (!access.receive_enabled) {
return privateNoStore(errorResponseFromCode('PEPPOL_RECEIVING_NOT_ENABLED', log, { requestId }))
}
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('org_number, company_name, vat_number, city, country')
.eq('company_id', companyId)
.single()
if (settingsError || !settings) {
return privateNoStore(errorResponseFromCode('INVOICE_SEND_COMPANY_SETTINGS_MISSING', log, { requestId }))
}
try {
const result = await registerCompanyForPeppolReceiving({
service: createServiceClient(),
companyId,
userId: user.id,
transport: resolved.transport,
settings: settings as Pick<CompanySettings, 'org_number' | 'company_name' | 'vat_number' | 'city' | 'country'>,
})
if (!result.ok) {
return privateNoStore(errorResponseFromCode(result.code, log, {
requestId,
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
}))
}
return privateNoStore(NextResponse.json({
data: { registration: registrationPayload(result.registration) },
}, { status: 201 }))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)
/** DELETE /api/settings/peppol: withdraw the identifier from the Access Point. */
export const DELETE = withRouteContext(
'settings.peppol.deregister',
async (_request, { companyId, log, requestId }) => {
const resolved = resolveTransport()
if (!resolved) {
return privateNoStore(errorResponseFromCode('PEPPOL_TRANSPORT_UNAVAILABLE', log, { requestId }))
}
try {
const result = await deregisterCompanyFromPeppolReceiving({
service: createServiceClient(),
companyId,
transport: resolved.transport,
})
if (!result.ok) {
return privateNoStore(errorResponseFromCode(result.code, log, {
requestId,
...('detail' in result && result.detail ? { details: { reason: result.detail } } : {}),
}))
}
return privateNoStore(NextResponse.json({
data: { registration: registrationPayload(result.registration) },
}))
} catch (err) {
return privateNoStore(errorResponse(err, log, { requestId }))
}
},
{ requireWrite: true },
)