* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
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 { syncStripeConnection } from './lib/sync'
|
|
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, marks invoices
|
|
* paid from Stripe checkout events, and books payouts (gross/fees/net) against
|
|
* the 1686 clearing account.
|
|
*
|
|
* 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: '/settings/payments',
|
|
},
|
|
|
|
// 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
|
|
|
|
// Events + payouts the deterministic matcher refused to auto-apply.
|
|
// Members can read both ledgers under RLS; the panel lists them for
|
|
// manual handling.
|
|
const { data: reviewRows, count: reviewCount } = await supabase
|
|
.from('stripe_payment_events')
|
|
.select('id, reason, amount, currency, invoice_id, event_created_at', {
|
|
count: 'exact',
|
|
})
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('status', 'needs_review')
|
|
.order('event_created_at', { ascending: false })
|
|
.limit(5)
|
|
|
|
const { data: payoutRows, count: payoutCount } = await supabase
|
|
.from('stripe_payouts')
|
|
.select('id, reason, amount, currency, event_created_at', { count: 'exact' })
|
|
.eq('company_id', ctx.companyId)
|
|
.eq('status', 'needs_review')
|
|
.order('event_created_at', { ascending: false })
|
|
.limit(5)
|
|
|
|
const payload: StripeStatusResponse = {
|
|
configured: isStripeConnectConfigured(),
|
|
connection,
|
|
needs_review_count: (reviewCount ?? 0) + (payoutCount ?? 0),
|
|
needs_review: [
|
|
...(reviewRows ?? []),
|
|
...(payoutRows ?? []).map((p) => ({ ...p, invoice_id: null })),
|
|
],
|
|
}
|
|
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
|
|
const summary = await syncStripeConnection(serviceClient, typedConnection)
|
|
// The manual button covers both feeds: when the balance-transaction
|
|
// feed is enabled, "Synka nu" also pulls it (same module as the
|
|
// nightly cron, no separate rate limit needed: one user action).
|
|
const transactions = typedConnection.transaction_sync_enabled
|
|
? await syncStripeBalanceTransactions(serviceClient, typedConnection)
|
|
: undefined
|
|
return NextResponse.json({ success: true, ...summary, 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<StripeConnection, 'id' | 'status' | 'stripe_account_id'>
|
|
| 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
|