* feat(api): v1 invoice :mark-sent action verb (Phase 2 PR-B-2b-1)
First invoice action verb. Transitions a DRAFT invoice to 'sent' status —
intended for invoices delivered outside gnubok (Peppol, postal, custom
SMTP). The full :send pipeline (PDF + email) builds on top of this in
PR-B-2b-3.
URL convention: plain /verb subpath (e.g. /invoices/:id/mark-sent), not
the AIP-style :verb suffix the plan originally proposed. Next.js routes
don't support `:` in folder names, and the Stripe/QuickBooks idiom is
plain subpaths anyway. The agent-facing docs can still describe the
action however we want.
What happens on commit:
1. F-series invoice_number allocated atomically via the
generate_invoice_number RPC (per the PR-B-2a design — drafts have
invoice_number=null until this transition, preserving the unbroken
löpnummer series required by ML 17 kap 24§ p.2).
2. Status flips draft → sent.
3. For accrual + real invoices, posts the invoice journal entry via
createInvoiceJournalEntry (Debit AR 1510 / Credit revenue 3xxx /
Credit output VAT 26xx). Cash basis skips this; booking happens at
payment time.
4. Writes journal_entry_id back onto the invoice row.
5. Emits invoice.sent.
Race-condition guard: the status update matches .eq('status', 'draft'),
so a concurrent transition between pre-flight and update returns 409
INVOICE_UPDATE_NOT_DRAFT.
Dry-run: returns a preview of the post-send invoice state including a
would_create_journal_entry flag and the resolved accounting_method.
invoice_number can't be predicted exactly (atomic sequence allocation)
so the preview shows a marker rather than a fake number.
PDF archival is deliberately NOT in this PR. The internal route does
it, but PDF rendering + document upload is a meaningful surface area
that belongs with :send (PR-B-2b-3) where email + PDF land together.
Test infrastructure: the makeFlexibleSupabase mock now supports
per-table result QUEUES (array form returns results in order across
multiple calls; single value returns same result every time). Required
to mock the pre-flight read (status=draft) and post-update read
(status=sent) on the same `invoices` table inside one request.
9 new tests covering happy path, idempotency, scope, draft-only guard,
delivery-note rejection, 404, UUID validation, dry-run preview shape,
and cash-method skip. 3174/3174 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #454 review (Greptile + swarm + Swedish compliance)
Real bugs / contract violations:
- Greptile P1 (guard order): the delivery_note guard ran AFTER the
status check, so a sent delivery note returned 409 instead of the
documented 400. Reordered: document-shape guards (delivery_note +
credit_note + missing moms_ruta) now run before the status check.
- Greptile P1 (journal_entry_id write-back): Supabase returns
{ data, error } and never rejects on DB errors, so a write-back
failure produced no log and left the invoice with a real journal
entry but no pointer. Now destructured + escalated to error log AND
surfaced as a warning in the response.
- Swedish: credit notes (credited_invoice_id !== null) were not
rejected — they would have been posted via createInvoiceJournalEntry
with the wrong sign (Debit AR / Credit revenue instead of the
inverse). Now explicitly rejected; credit-note path goes through
POST /:id/credit (PR-B-2b-4).
- Swedish: moms_ruta now validated in the pre-flight. A null value
would silently default to 25% domestic in the journal-entry
generator — wrong for reverse-charge / EU-service / zero-rated
invoices. Real ML 17 kap 24§ concern.
Partial-state visibility (Swedish + Swarm V2.3 + A.8.15 + PI1.3):
The response now carries an optional `warnings: [{ code, message }]`
field when the status flip succeeded but a follow-up step failed
(journal entry creation, event emission, or journal_entry_id write-
back). Three warning codes:
- JOURNAL_ENTRY_NOT_POSTED — verifikation missing; BFL 5 kap
reconciliation required
- JOURNAL_ENTRY_ID_WRITEBACK_FAILED — entry exists but invoice row
has no pointer
- EVENT_EMIT_FAILED — webhook subscribers may miss this transition
All three escalate to error-level logs. The architectural fix
(transactional Postgres RPC that bundles allocation + status flip
+ journal entry) is tracked as cross-surface compliance work; the
warnings field is the agent-facing signal until that lands.
The F-series race window (number allocated before status flip; a
concurrent transition can leave a consumed-but-orphaned number, ML
17 kap 24§ p.2 gap) is now explicitly documented in the route
docstring rather than hidden in implementation. Same residual issue
exists in the internal route; fix needs the transactional RPC.
Pushing back (consistent with prior triage):
- V8.2.1 explicit ownership check (wrapper handles — false positive)
- Cross-tenant IDOR test (duplicates wrapper test coverage)
- Pseudonymise IDs in logs (operational value > theoretical risk)
- Structured audit event sink (current ctx.log.info IS structured)
- Test fixture A.8.33 (NODE_ENV guard in place; Acme AB is canonical
synthetic placeholder)
- Projection column narrowing (fields ARE used in the flow)
- company_settings hard-fail on miss (accrual default is normal)
3 new tests covering credit-note rejection, missing moms_ruta, and
the journal-entry-failed warnings path. Plus the delivery-note test
now asserts the guard ordering works for sent delivery notes too.
3177/3177 vitest pass; build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
4.4 KiB
TypeScript
114 lines
4.4 KiB
TypeScript
/**
|
|
* v1 REST API endpoint → required scope map.
|
|
*
|
|
* This is the REST-route analogue of `TOOL_SCOPE_MAP` in api-keys.ts (which
|
|
* maps MCP tool names to scopes). Both share the same `ApiKeyScope` registry.
|
|
*
|
|
* Key format: `<METHOD> <pattern>` where pattern uses `:param` for path
|
|
* variables, matching Next.js dynamic-segment conventions (one for one).
|
|
*
|
|
* Endpoints not listed here are public (no auth) — only the discovery routes
|
|
* (`/llms.txt`, `/.well-known/skills`, `/api/v1/health`, `/api/v1/openapi.json`)
|
|
* fall into that bucket. Everything else under `/api/v1/` MUST be in this map
|
|
* or the wrapper will refuse the request with INSUFFICIENT_SCOPE.
|
|
*/
|
|
|
|
import type { ApiKeyScope } from './api-keys'
|
|
|
|
/**
|
|
* Routes that require authentication but no scope check beyond "is the key
|
|
* valid?". The wrapper still validates the key and runs rate limiting.
|
|
*/
|
|
export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
|
|
'GET /api/v1/health',
|
|
'GET /api/v1/openapi.json',
|
|
'GET /api/v1/openapi.yaml',
|
|
]
|
|
|
|
/**
|
|
* Map of v1 endpoint pattern → required scope.
|
|
*
|
|
* Patterns use `:param` placeholders that match a single path segment.
|
|
* The wrapper compiles these into regexes at startup and matches incoming
|
|
* requests by (method, normalized-path) tuple.
|
|
*
|
|
* When adding a new endpoint, add it here BEFORE shipping the route file —
|
|
* otherwise the wrapper will reject all requests to it.
|
|
*/
|
|
export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
|
// Companies
|
|
'GET /api/v1/companies': 'companies:read',
|
|
'GET /api/v1/companies/:companyId': 'companies:read',
|
|
|
|
// Operations (async long-running tasks)
|
|
'GET /api/v1/operations/:id': 'operations:read',
|
|
|
|
// Events (webhook fallback / event log polling)
|
|
'GET /api/v1/companies/:companyId/events': 'events:read',
|
|
|
|
// Customers (Phase 2 PR-A — reads; Phase 2 PR-B-1 — writes)
|
|
'GET /api/v1/companies/:companyId/customers': 'customers:read',
|
|
'GET /api/v1/companies/:companyId/customers/:id': 'customers:read',
|
|
'POST /api/v1/companies/:companyId/customers': 'customers:write',
|
|
'PATCH /api/v1/companies/:companyId/customers/:id': 'customers:write',
|
|
'DELETE /api/v1/companies/:companyId/customers/:id': 'customers:write',
|
|
|
|
// Invoices (Phase 2 PR-A — reads; Phase 2 PR-B-2a — draft writes)
|
|
'GET /api/v1/companies/:companyId/invoices': 'invoices:read',
|
|
'GET /api/v1/companies/:companyId/invoices/:id': 'invoices:read',
|
|
'POST /api/v1/companies/:companyId/invoices': 'invoices:write',
|
|
'PATCH /api/v1/companies/:companyId/invoices/:id': 'invoices:write',
|
|
// Phase 2 PR-B-2b — action verbs. URL uses /verb subpath (not Google-AIP-style :verb)
|
|
// because Next.js routes don't support `:` in folder names.
|
|
'POST /api/v1/companies/:companyId/invoices/:id/mark-sent': 'invoices:write',
|
|
|
|
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
|
|
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
|
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
|
'GET /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
'PATCH /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
'DELETE /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage',
|
|
}
|
|
|
|
interface CompiledRoute {
|
|
method: string
|
|
regex: RegExp
|
|
scope: ApiKeyScope
|
|
}
|
|
|
|
let compiledCache: CompiledRoute[] | null = null
|
|
|
|
function compileAll(): CompiledRoute[] {
|
|
if (compiledCache) return compiledCache
|
|
compiledCache = Object.entries(V1_ENDPOINT_SCOPES).map(([pattern, scope]) => {
|
|
const [method, path] = pattern.split(' ', 2)
|
|
const regexStr = '^' + path.replace(/:[^/]+/g, '[^/]+') + '$'
|
|
return { method, regex: new RegExp(regexStr), scope }
|
|
})
|
|
return compiledCache
|
|
}
|
|
|
|
/**
|
|
* Resolve the required scope for a given (method, path) request.
|
|
*
|
|
* - Returns the scope when a registered v1 endpoint matches.
|
|
* - Returns 'public' for paths in V1_PUBLIC_ENDPOINTS (no scope check needed,
|
|
* but the wrapper may still want to log the key id).
|
|
* - Returns null when the path is unknown — the wrapper should treat this as
|
|
* a 404 NOT_FOUND rather than letting the request through unauthenticated.
|
|
*/
|
|
export function resolveRequiredScope(method: string, path: string): ApiKeyScope | 'public' | null {
|
|
const key = `${method} ${path}`
|
|
|
|
if (V1_PUBLIC_ENDPOINTS.includes(key)) return 'public'
|
|
|
|
const compiled = compileAll()
|
|
for (const route of compiled) {
|
|
if (route.method === method && route.regex.test(path)) {
|
|
return route.scope
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|