import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' import { checkRateLimit } from '@/lib/auth/rate-limit-http' import { requireCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard' import { buildAuthorizeUrl, deauthorizeAccount, isStripeConnectConfigured, isLiveMode, } from './lib/connect' import { createInvoicePaymentLink, handleCreditNoteCreated, handleInvoicePaid, } from './lib/payment-links' import { syncStripeBalanceTransactions } from './lib/transaction-sync' import { createServiceClientNoCookies } from '@/lib/auth/api-keys' import type { StripeConnection, StripeStatusResponse } from './types' // Per-user limits: connect/disconnect are outward-facing OAuth operations, // sync hits the Stripe API. const RATE_LIMIT_CONNECT = { maxRequests: 10, windowMs: 60_000 } const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 } const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 } // A pending row younger than this blocks a second connect attempt so a // double-click cannot start two OAuth round-trips (only one state would // survive, stranding the other at the callback). const PENDING_FRESH_MS = 60_000 const NOT_CONFIGURED_MESSAGE = 'Stripe-integrationen är inte konfigurerad på den här installationen.' /** * Stripe Connect extension * * Connects a company's Stripe account via Connect OAuth (Standard accounts). * Auto-creates a Stripe Payment Link when an invoice is sent, and imports the * account's balance transactions into the transactions inbox as a bank feed * for the Stripe balance (1686). Feed-only by decision 2026-07-24: nothing is * auto-booked; the event/settlement sync (lib/sync.ts, lib/payouts.ts) is * retained in the repo but not wired to any cron or route. * * Required environment variables: * - STRIPE_SECRET_KEY (the platform account key, shared with billing) * - STRIPE_CONNECT_CLIENT_ID (ca_... from the platform's Connect settings) */ export const stripeExtension: Extension = { id: 'stripe', name: 'Stripe-betalningar', version: '1.0.0', settingsPanel: { label: 'Betalningar (Stripe)', path: '/import?mode=stripe', }, // Core-callable services, resolved via the extension registry (core never // imports extension code). The send routes use this to auto-fill // invoices.payment_link_url before the email/PDF render. services: { createInvoicePaymentLink, }, eventHandlers: [ // A settled or credited invoice must stop accepting money through its link. { eventType: 'invoice.paid', handler: handleInvoicePaid }, { eventType: 'credit_note.created', handler: handleCreditNoteCreated }, ], apiRoutes: [ { method: 'GET', path: '/status', handler: async (_request: Request, ctx?: ExtensionContext) => { const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!ctx?.companyId) { return NextResponse.json({ error: 'Company context required' }, { status: 400 }) } // Prefer the active connection; otherwise surface the most recent row // so the panel can show pending/error/revoked states. const { data: rows } = await supabase .from('stripe_connections') .select( 'id, status, stripe_account_id, livemode, display_name, error_message, connected_at, last_event_created_at, transaction_sync_enabled, last_balance_txn_synced_at', ) .eq('company_id', ctx.companyId) .order('created_at', { ascending: false }) .limit(10) const connection = rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null const payload: StripeStatusResponse = { configured: isStripeConnectConfigured(), connection, } return NextResponse.json(payload) }, }, { method: 'POST', path: '/sync', handler: async (_request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!ctx?.companyId) { return NextResponse.json({ error: 'Company context required' }, { status: 400 }) } const companyId = ctx.companyId const capabilityBlocked = await requireCapability( supabase, companyId, CAPABILITY.stripe_payments, ) if (capabilityBlocked) return capabilityBlocked const rl = await checkRateLimit({ prefix: 'stripe:sync', identifier: user.id, ...RATE_LIMIT_SYNC, }) if (!rl.ok) return rl.response! // Membership-scoped lookup via the user client; the sync itself runs // on the service client because the event ledger is service-write-only. const { data: connection } = await supabase .from('stripe_connections') .select('*') .eq('company_id', companyId) .eq('status', 'active') .maybeSingle() if (!connection) { return NextResponse.json({ error: 'Inget anslutet Stripe-konto.' }, { status: 404 }) } try { const serviceClient = createServiceClientNoCookies() const typedConnection = connection as StripeConnection // Feed-only (2026-07-24): Stripe sync imports balance transactions // into the inbox, nothing more. The event/settlement sync // (syncStripeConnection in lib/sync.ts) stays in the repo but is // deliberately not called: booking is a user decision in the inbox, // like any bank feed. The manual button ignores // transaction_sync_enabled (that flag gates the nightly cron): // pressing it IS the opt-in. const transactions = await syncStripeBalanceTransactions(serviceClient, typedConnection) return NextResponse.json({ success: true, transactions }) } catch (error) { log.error('[stripe] Manual sync failed', { message: error instanceof Error ? error.message : String(error), connection_id: connection.id, }) return NextResponse.json( { error: 'Synkroniseringen misslyckades. Försök igen.' }, { status: 502 }, ) } }, }, { method: 'POST', path: '/transaction-sync', handler: async (request: Request, ctx?: ExtensionContext) => { const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!ctx?.companyId) { return NextResponse.json({ error: 'Company context required' }, { status: 400 }) } const companyId = ctx.companyId const capabilityBlocked = await requireCapability( supabase, companyId, CAPABILITY.stripe_payments, ) if (capabilityBlocked) return capabilityBlocked const rl = await checkRateLimit({ prefix: 'stripe:transaction-sync-toggle', identifier: user.id, ...RATE_LIMIT_SYNC, }) if (!rl.ok) return rl.response! const body = (await request.json().catch(() => ({}))) as { enabled?: unknown } if (typeof body.enabled !== 'boolean') { return NextResponse.json( { error: 'enabled (boolean) krävs.' }, { status: 400 }, ) } const { data: updated, error: updateError } = await supabase .from('stripe_connections') .update({ transaction_sync_enabled: body.enabled }) .eq('company_id', companyId) .eq('status', 'active') .select('id') if (updateError) { return NextResponse.json( { error: 'Kunde inte spara inställningen. Försök igen.' }, { status: 500 }, ) } if (!updated || updated.length === 0) { return NextResponse.json({ error: 'Inget anslutet Stripe-konto.' }, { status: 404 }) } return NextResponse.json({ success: true, enabled: body.enabled }) }, }, { method: 'POST', path: '/connect', handler: async (_request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!ctx?.companyId) { return NextResponse.json({ error: 'Company context required' }, { status: 400 }) } const companyId = ctx.companyId // Anonymous/sandbox users must never reach Stripe (same doctrine as // billing checkout: the sandbox never talks to external services). // The anon check is identity truth and runs first (no DB round trip). if (user.is_anonymous) return sandboxBlockedResponse() const sandboxBlocked = await guardSandbox(supabase, companyId) if (sandboxBlocked) return sandboxBlocked const capabilityBlocked = await requireCapability( supabase, companyId, CAPABILITY.stripe_payments, ) if (capabilityBlocked) return capabilityBlocked const rl = await checkRateLimit({ prefix: 'stripe:connect', identifier: user.id, ...RATE_LIMIT_CONNECT, }) if (!rl.ok) return rl.response! if (!isStripeConnectConfigured()) { return NextResponse.json({ error: NOT_CONFIGURED_MESSAGE }, { status: 503 }) } const { data: existing } = await supabase .from('stripe_connections') .select('id, status, created_at') .eq('company_id', companyId) .in('status', ['active', 'pending']) .order('created_at', { ascending: false }) if (existing?.some((c) => c.status === 'active')) { return NextResponse.json( { error: 'Företaget har redan ett anslutet Stripe-konto. Koppla från det först.' }, { status: 409 }, ) } const pending = existing?.filter((c) => c.status === 'pending') ?? [] const freshPending = pending.find( (c) => Date.now() - new Date(c.created_at).getTime() < PENDING_FRESH_MS, ) if (freshPending) { return NextResponse.json( { error: 'En anslutning pågår redan. Vänta och försök igen.' }, { status: 409 }, ) } if (pending.length > 0) { // Supersede stale pending attempts so their oauth_state can never // complete a callback after this new round-trip starts. await supabase .from('stripe_connections') .update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null, }) .eq('company_id', companyId) .eq('status', 'pending') } // Persist the CSRF state BEFORE handing the user to Stripe: the // callback locates the row by oauth_state alone, so the row must // exist before Stripe can ever redirect back with that state. const oauthState = crypto.randomUUID() const { data: created, error: insertError } = await supabase .from('stripe_connections') .insert({ company_id: companyId, user_id: user.id, status: 'pending', oauth_state: oauthState, livemode: isLiveMode(), }) .select('id') .single() if (insertError || !created) { log.error('[stripe] Failed to stage pending connection', { message: insertError?.message, code: insertError?.code, companyId, }) return NextResponse.json( { error: 'Kunde inte starta anslutningen. Försök igen.' }, { status: 500 }, ) } log.info('[stripe] Starting Connect OAuth', { connection_id: created.id, company_id: companyId, livemode: isLiveMode(), }) return NextResponse.json({ url: buildAuthorizeUrl(oauthState) }) }, }, { method: 'DELETE', path: '/disconnect', handler: async (request: Request, ctx?: ExtensionContext) => { const log = ctx?.log ?? console const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } if (!ctx?.companyId) { return NextResponse.json({ error: 'Company context required' }, { status: 400 }) } const companyId = ctx.companyId const rl = await checkRateLimit({ prefix: 'stripe:disconnect', identifier: user.id, ...RATE_LIMIT_DISCONNECT, }) if (!rl.ok) return rl.response! const body = (await request.json().catch(() => ({}))) as { connection_id?: string } const base = supabase .from('stripe_connections') .select('id, status, stripe_account_id') .eq('company_id', companyId) const query = body.connection_id ? base.eq('id', body.connection_id).limit(1) : base.neq('status', 'revoked').order('created_at', { ascending: false }).limit(1) const { data: rows, error: findError } = await query const connection = rows?.[0] as | Pick | undefined if (findError || !connection) { return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) } // Best-effort revoke at Stripe: an already-severed connection throws, // which is fine (the goal state is reached either way). Logged at WARN // so a systematic revoke failure stays visible to monitoring. if (connection.status === 'active' && connection.stripe_account_id) { try { await deauthorizeAccount(connection.stripe_account_id) } catch (revokeError) { log.warn('[stripe] Deauthorize skipped (likely already revoked)', { message: revokeError instanceof Error ? revokeError.message : String(revokeError), connection_id: connection.id, }) } } const { error: updateError } = await supabase .from('stripe_connections') .update({ status: 'revoked', oauth_state: null, disconnected_at: new Date().toISOString(), }) .eq('id', connection.id) .eq('company_id', companyId) if (updateError) { log.error('[stripe] Failed to mark connection revoked', { message: updateError.message, connection_id: connection.id, }) return NextResponse.json( { error: 'Kunde inte koppla från. Försök igen.' }, { status: 500 }, ) } if (ctx?.emit) { try { await ctx.emit({ type: 'stripe.disconnected', payload: { connectionId: connection.id, stripeAccountId: connection.stripe_account_id, reason: 'user', userId: user.id, companyId, }, }) } catch { // Audit event failure must not block the disconnect itself. } } return NextResponse.json({ success: true }) }, }, ], } export default stripeExtension