From 3af95bec3f06b414b05992ca5f5027d49914d47d Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 21 Aug 2026 17:57:10 +0200 Subject: [PATCH] feat(peppol): receiving sits behind the access request, switch only once granted (#1795) The settings group showed the receiving switch (disabled) and a status row to every company, which read as "anyone can receive". Now the switch and its status exist only once the operators granted receiving (or a registration already exists that the company must be able to see and withdraw), and the access request carries a "we also want to receive" checkbox that lands in the request note and the support mail (with --receive in the enable command). The access line says whether receiving is included. 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 --- .../peppol/access/__tests__/route.test.ts | 6 +- app/api/settings/peppol/access/route.ts | 18 ++++-- components/settings/PeppolReceiveSettings.tsx | 64 +++++++++++-------- messages/en.json | 5 +- messages/sv.json | 5 +- 5 files changed, 62 insertions(+), 36 deletions(-) diff --git a/app/api/settings/peppol/access/__tests__/route.test.ts b/app/api/settings/peppol/access/__tests__/route.test.ts index 1f474bff..5a1ec581 100644 --- a/app/api/settings/peppol/access/__tests__/route.test.ts +++ b/app/api/settings/peppol/access/__tests__/route.test.ts @@ -92,7 +92,7 @@ describe('POST /api/settings/peppol/access', () => { enqueue({ data: { company_name: 'Kund AB', org_number: '556677-8899' }, error: null }) // company settings service.enqueue({ data: requestedRow, error: null }) // summary read - const response = await post({ note: 'Vi fakturerar Region Skåne' }) + const response = await post({ note: 'Vi fakturerar Region Skåne', wants_receiving: true }) const body = await response.json() expect(response.status).toBe(201) @@ -101,8 +101,12 @@ describe('POST /api/settings/peppol/access', () => { const mail = sendEmailMock.mock.calls[0][0] as { to: string; subject: string; text: string } expect(mail.to).toBe('support@example.test') expect(mail.subject).toContain('Kund AB') + expect(mail.subject).toContain('mottagning') expect(mail.text).toContain('company-1') expect(mail.text).toContain('Region Skåne') + expect(mail.text).toContain('--receive') + const upsert = service.calls.find((c) => c.method === 'upsert')?.args[0] as Record + expect(upsert.request_note).toBe('[vill ta emot e-fakturor] Vi fakturerar Region Skåne') }) it('is idempotent for a repeated request (no second e-mail) and 409 when already enabled', async () => { diff --git a/app/api/settings/peppol/access/route.ts b/app/api/settings/peppol/access/route.ts index 268eecca..63579634 100644 --- a/app/api/settings/peppol/access/route.ts +++ b/app/api/settings/peppol/access/route.ts @@ -18,7 +18,9 @@ import { getSupportRecipientEmail } from '@/lib/support' ensureInitialized() const RequestAccessSchema = z.object({ - note: z.string().trim().max(2000).optional(), + note: z.string().trim().max(1800).optional(), + /** The company also wants to receive (one of the contracted tenant slots). */ + wants_receiving: z.boolean().optional(), }) function escapeHtml(value: string): string { @@ -37,7 +39,13 @@ export const POST = withRouteContext( async (request, { supabase, companyId, user, log, requestId }) => { const validation = await validateBody(request, RequestAccessSchema) if (!validation.success) return validation.response - const note = validation.data.note?.trim() || null + const wantsReceiving = validation.data.wants_receiving === true + const userNote = validation.data.note?.trim() || null + // The receiving wish travels in the request note so the operators see it + // in `access.ts list` and in the mail, and grant it with --receive. + const note = [wantsReceiving ? '[vill ta emot e-fakturor]' : null, userNote] + .filter((part): part is string => !!part) + .join(' ') || null if (await isSandboxCompany(supabase, companyId)) { return privateNoStore(errorResponseFromCode('PEPPOL_SANDBOX_NOT_ALLOWED', log, { requestId })) @@ -62,16 +70,16 @@ export const POST = withRouteContext( const orgNumber = (company as { org_number?: string | null } | null)?.org_number ?? 'saknas' const sent = await emailService.sendEmail({ to: getSupportRecipientEmail(), - subject: `[${getBranding().appName.toLowerCase()} peppol] Åtkomstbegäran: ${companyName}`, + subject: `[${getBranding().appName.toLowerCase()} peppol] Åtkomstbegäran${wantsReceiving ? ' (+ mottagning)' : ''}: ${companyName}`, replyTo: user.email, html: [ `

Bolag: ${escapeHtml(companyName)} (${escapeHtml(orgNumber)})

`, `

Company ID: ${companyId}

`, `

Begärd av: ${escapeHtml(user.email ?? '')} (${user.id})

`, note ? `

${escapeHtml(note).replace(/\n/g, '
')}

` : '', - `

Aktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50

`, + `

Aktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50${wantsReceiving ? ' --receive' : ''}

`, ].join('\n'), - text: `Bolag: ${companyName} (${orgNumber})\nCompany ID: ${companyId}\nBegärd av: ${user.email ?? ''} (${user.id})\n\n${note ?? ''}\n\nAktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50`, + text: `Bolag: ${companyName} (${orgNumber})\nCompany ID: ${companyId}\nBegärd av: ${user.email ?? ''} (${user.id})\n\n${note ?? ''}\n\nAktivera: npx tsx --env-file=.env.local scripts/peppol/access.ts enable ${companyId} --max-sends 50${wantsReceiving ? ' --receive' : ''}`, }) if (!sent.success) { log.warn('peppol access request e-mail failed', { companyId, reason: sent.error }) diff --git a/components/settings/PeppolReceiveSettings.tsx b/components/settings/PeppolReceiveSettings.tsx index 970b4f09..0d29e532 100644 --- a/components/settings/PeppolReceiveSettings.tsx +++ b/components/settings/PeppolReceiveSettings.tsx @@ -55,6 +55,7 @@ export function PeppolReceiveSettings() { const [loadFailed, setLoadFailed] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isRequesting, setIsRequesting] = useState(false) + const [wantsReceiving, setWantsReceiving] = useState(false) const load = useCallback(async () => { try { @@ -86,7 +87,7 @@ export function PeppolReceiveSettings() { const response = await fetch('/api/settings/peppol/access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ wants_receiving: wantsReceiving }), }) const body = await response.json().catch(() => null) as { error?: { code?: string; message?: string; message_en?: string } @@ -103,7 +104,7 @@ export function PeppolReceiveSettings() { } finally { setIsRequesting(false) } - }, [load, localeKey, t, toast]) + }, [load, localeKey, t, toast, wantsReceiving]) const toggleReceiving = useCallback(async (next: boolean) => { setIsSaving(true) @@ -133,7 +134,7 @@ export function PeppolReceiveSettings() { const accessLine = (() => { if (!access) return null switch (access.status) { - case 'enabled': return t('access_enabled') + case 'enabled': return access.receive_enabled ? t('access_enabled_receiving') : t('access_enabled_send_only') case 'requested': return t('access_requested') case 'disabled': return t('access_disabled') default: return t('access_none') @@ -166,7 +167,17 @@ export function PeppolReceiveSettings() { )} {state !== null && transportAvailable && (access?.status === 'none' || access?.status === 'disabled') && ( - + +