bac49b6ee6
* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { extensionRegistry } from '@/lib/extensions/registry'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
|
|
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
|
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
|
|
|
ensureInitialized()
|
|
|
|
// Heavy extension routes (SIE import, migration) need up to 5 minutes
|
|
export const maxDuration = 300
|
|
|
|
/**
|
|
* Match a request path against a route pattern.
|
|
* Supports :param wildcards (e.g., /:id/confirm).
|
|
* Returns extracted params on match, null on mismatch.
|
|
*/
|
|
function matchPath(
|
|
pattern: string,
|
|
requestPath: string
|
|
): Record<string, string> | null {
|
|
const patternParts = pattern.split('/').filter(Boolean)
|
|
const requestParts = requestPath.split('/').filter(Boolean)
|
|
|
|
if (patternParts.length !== requestParts.length) return null
|
|
|
|
const params: Record<string, string> = {}
|
|
|
|
for (let i = 0; i < patternParts.length; i++) {
|
|
if (patternParts[i].startsWith(':')) {
|
|
params[patternParts[i].slice(1)] = requestParts[i]
|
|
} else if (patternParts[i] !== requestParts[i]) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
/**
|
|
* Catch-all route for extension-declared API routes.
|
|
*
|
|
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
|
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
|
|
*
|
|
* - Looks up the extension in the registry
|
|
* - Checks the extension toggle (disabled → 403)
|
|
* - Matches method + path pattern to registered apiRoutes
|
|
* - Extracts path params and appends them as URL search params
|
|
* - Builds an ExtensionContext and passes it to the handler
|
|
*/
|
|
async function handleRequest(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
): Promise<Response> {
|
|
const segments = await params
|
|
|
|
if (!segments.path || segments.path.length < 1) {
|
|
return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 })
|
|
}
|
|
|
|
const [extensionId, ...rest] = segments.path
|
|
const routePath = '/' + rest.join('/')
|
|
const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
|
|
// Look up extension
|
|
const extension = extensionRegistry.get(extensionId)
|
|
if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) {
|
|
return NextResponse.json({ error: 'Extension not found' }, { status: 404 })
|
|
}
|
|
|
|
// Match route BEFORE auth so we can check skipAuth (e.g. OAuth callbacks)
|
|
let matchedRoute: ApiRouteDefinition | null = null
|
|
let extractedParams: Record<string, string> = {}
|
|
|
|
for (const route of extension.apiRoutes) {
|
|
if (route.method !== method) continue
|
|
|
|
const routeParams = matchPath(route.path, routePath)
|
|
if (routeParams !== null) {
|
|
matchedRoute = route
|
|
extractedParams = routeParams
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!matchedRoute) {
|
|
return NextResponse.json({ error: 'Route not found' }, { status: 404 })
|
|
}
|
|
|
|
// For skipAuth routes (e.g. OAuth callbacks from external providers),
|
|
// skip user auth, toggle check, and AI consent — dispatch immediately
|
|
if (matchedRoute.skipAuth) {
|
|
let handlerRequest = request
|
|
if (Object.keys(extractedParams).length > 0) {
|
|
const url = new URL(request.url)
|
|
for (const [key, value] of Object.entries(extractedParams)) {
|
|
url.searchParams.set(`_${key}`, value)
|
|
}
|
|
handlerRequest = new Request(url.toString(), {
|
|
method: request.method,
|
|
headers: request.headers,
|
|
body: request.body,
|
|
// @ts-expect-error -- duplex needed for streaming body
|
|
duplex: 'half',
|
|
})
|
|
}
|
|
return matchedRoute.handler(handlerRequest)
|
|
}
|
|
|
|
// Auth check
|
|
const supabase = await createClient()
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
// Toggle check — use extension's declared sector, fallback to 'general'
|
|
const sector = extension.sector || 'general'
|
|
const enabled = await isExtensionEnabled(user.id, sector, extensionId)
|
|
if (!enabled) {
|
|
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
|
|
}
|
|
|
|
// AI consent check
|
|
if (isAiExtension(extensionId)) {
|
|
const consented = await hasAiConsent(supabase, user.id, extensionId)
|
|
if (!consented) {
|
|
return NextResponse.json(
|
|
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
|
{ status: 403 }
|
|
)
|
|
}
|
|
}
|
|
|
|
// If path params were extracted, create a new Request with them as search params
|
|
let handlerRequest = request
|
|
if (Object.keys(extractedParams).length > 0) {
|
|
const url = new URL(request.url)
|
|
for (const [key, value] of Object.entries(extractedParams)) {
|
|
url.searchParams.set(`_${key}`, value)
|
|
}
|
|
handlerRequest = new Request(url.toString(), {
|
|
method: request.method,
|
|
headers: request.headers,
|
|
body: request.body,
|
|
// @ts-expect-error -- duplex needed for streaming body
|
|
duplex: 'half',
|
|
})
|
|
}
|
|
|
|
// Build context and dispatch
|
|
const ctx = createExtensionContext(supabase, user.id, extensionId)
|
|
return matchedRoute.handler(handlerRequest, ctx)
|
|
}
|
|
|
|
export const GET = handleRequest
|
|
export const POST = handleRequest
|
|
export const PUT = handleRequest
|
|
export const DELETE = handleRequest
|
|
export const PATCH = handleRequest
|