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>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
Reference in New Issue
Block a user