Files
accounted/scripts/peppol/access.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

132 lines
5.6 KiB
TypeScript

/**
* Operator tool for Peppol access (service role; .env.local points at prod).
*
* npx tsx --env-file=.env.local scripts/peppol/access.ts list
* Open requests first, then every company with a row (status, cap, sends used).
* npx tsx --env-file=.env.local scripts/peppol/access.ts enable <company_id|orgnr> [--max-sends N] [--receive] [--by you@accounted.se] [--note "..."]
* Grants sending (capped at N transmissions, omit for no cap) and, with
* --receive, the right to publish the company's identifier (one contracted
* tenant slot). Re-running adjusts the same row.
* npx tsx --env-file=.env.local scripts/peppol/access.ts disable <company_id|orgnr> [--note "..."]
* npx tsx --env-file=.env.local scripts/peppol/access.ts show <company_id|orgnr>
*
* Nothing here talks to Qvalia; it only flips what the product allows.
*/
import { createClient } from '@supabase/supabase-js'
import {
countPeppolSends,
getPeppolAccess,
setPeppolAccess,
} from '@/lib/invoices/peppol-access'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !serviceRoleKey) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY (run with --env-file=.env.local)')
process.exit(2)
}
const service = createClient(supabaseUrl, serviceRoleKey)
const [, , command = 'list', target, ...rest] = process.argv
function flag(name: string): string | null {
const index = rest.indexOf(`--${name}`)
if (index === -1) return null
return rest[index + 1] ?? ''
}
async function resolveCompanyId(input: string | undefined): Promise<string> {
if (!input) throw new Error('company id or organisation number required')
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input)) return input
const digits = input.replace(/\D/g, '')
const { data, error } = await service
.from('company_settings')
.select('company_id, company_name, org_number')
.limit(2000)
if (error) throw error
const hits = ((data ?? []) as Array<{ company_id: string; company_name: string | null; org_number: string | null }>)
.filter((row) => (row.org_number ?? '').replace(/\D/g, '') === digits)
if (hits.length !== 1) throw new Error(`${hits.length} companies match org number ${input}`)
return hits[0].company_id
}
async function describe(companyId: string): Promise<string> {
const { data } = await service
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', companyId)
.maybeSingle()
const row = data as { company_name: string | null; org_number: string | null } | null
return `${row?.company_name ?? '?'} (${row?.org_number ?? '?'}) ${companyId}`
}
async function main(): Promise<void> {
switch (command) {
case 'list': {
const { data, error } = await service
.from('peppol_access')
.select('*')
.order('status', { ascending: true })
.order('requested_at', { ascending: true })
if (error) throw error
const rows = (data ?? []) as Array<{ company_id: string; status: string; max_sends: number | null; receive_enabled: boolean; requested_at: string | null; enabled_at: string | null; request_note: string | null }>
if (rows.length === 0) { console.log('No Peppol access rows.'); return }
for (const row of rows) {
const sends = row.status === 'enabled' ? await countPeppolSends(service, row.company_id) : 0
console.log(
`${row.status.padEnd(9)} ${await describe(row.company_id)} sends ${sends}/${row.max_sends ?? '∞'} receive=${row.receive_enabled}`
+ (row.requested_at ? ` requested ${row.requested_at}` : '')
+ (row.request_note ? `\n note: ${row.request_note}` : ''),
)
}
return
}
case 'show': {
const companyId = await resolveCompanyId(target)
console.log(await describe(companyId))
console.log(JSON.stringify(await getPeppolAccess(service, companyId), null, 2))
console.log('sends used:', await countPeppolSends(service, companyId))
return
}
case 'enable': {
const companyId = await resolveCompanyId(target)
const maxSendsRaw = flag('max-sends')
const maxSends = maxSendsRaw === null ? undefined : (maxSendsRaw === '' || maxSendsRaw === 'none' ? null : Number.parseInt(maxSendsRaw, 10))
if (maxSends !== undefined && maxSends !== null && !Number.isFinite(maxSends)) throw new Error('--max-sends expects a number or "none"')
const row = await setPeppolAccess({
service,
companyId,
status: 'enabled',
maxSends,
receiveEnabled: rest.includes('--receive') ? true : (rest.includes('--no-receive') ? false : undefined),
by: flag('by') ?? `scripts/peppol/access.ts (${process.env.USER ?? 'operator'})`,
note: flag('note') ?? undefined,
})
console.log(`enabled: ${await describe(companyId)}`)
console.log(JSON.stringify(row, null, 2))
return
}
case 'disable': {
const companyId = await resolveCompanyId(target)
const row = await setPeppolAccess({
service,
companyId,
status: 'disabled',
by: flag('by') ?? `scripts/peppol/access.ts (${process.env.USER ?? 'operator'})`,
note: flag('note') ?? undefined,
})
console.log(`disabled: ${await describe(companyId)}`)
console.log(JSON.stringify(row, null, 2))
return
}
default:
throw new Error(`Unknown command "${command}". See the header comment.`)
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})