Files
accounted/app/(dashboard)/layout.tsx
T
MattssonandClaude Fable 5 d840257c0c Add/stripe connect transactions (#1139)
* 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>
2026-07-24 01:16:20 +02:00

337 lines
14 KiB
TypeScript

import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
import type { EntityType, CompanyRole, Team } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
/**
* Routes inside the dashboard group that must remain reachable when the
* user has no active company. Keep in sync with the middleware's
* no-company allowlist.
*/
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
/**
* Frame layout: on desktop the page is a rounded panel floating on the
* warm-toned frame (bg-frame on the wrapper div), with its own inner
* scroll. 10px margin against the frame; height is the remaining
* viewport. The sidebar (fixed, w-64) sits borderless on the frame, so
* the panel starts at ml-64. On mobile the panel dissolves: full-width
* document flow with the bottom nav, exactly as before.
*/
const MAIN_PANEL_CLASS =
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[10px] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
export default async function DashboardLayout({
children,
settingsModal,
}: {
children: React.ReactNode
// `@settingsModal` parallel slot: renders the routed settings modal over the
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
// Resolve active company from user_preferences (authoritative). The
// `gnubok-company-id` cookie is intentionally no longer consulted here:
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
// Team membership (with the team row embedded) only depends on user.id,
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
getDashboardCompanyId(),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
.from('team_members')
.select('team_id, role, teams:team_id(*)')
.eq('user_id', user.id)
.limit(1)
.maybeSingle(),
])
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
const team: Team | null =
(teamMembership?.teams as unknown as Team | null) ?? null
const isTeamMember = !!teamMembership
// No companies: redirect to onboarding, except for allowed escape-hatch
// routes (so the user can still reach /settings/account to delete their
// account after archiving their last company).
if (!companyId) {
if (!isNoCompanyAllowed) {
redirect('/onboarding')
}
return (
<CompanyProvider
value={{
company: null,
role: null,
companies: [],
isTeamMember,
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}}
>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className={MAIN_PANEL_CLASS}
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
// Fetch company + membership for context provider, together with the
// nav/badge data, none of these depend on each other, only on
// companyId/user.id, so one round-trip batch instead of two. The rare
// stale-cookie early return below wastes the extra reads; that's cheaper
// than serializing two batches on every dashboard render.
const [
{ data: companyRow },
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
agentProfileIdentity,
{ data: userProfile },
entitlements,
{ data: allSettingsNames },
{ data: userPrefs },
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
getDashboardSettings(),
// Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
getResolvedDashboardAgentProfile(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
getCompanyEntitlements(supabase, companyId),
// Current display names for ALL the user's companies (the switcher list).
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
// select returns exactly the caller's companies, letting non-active rows
// show company_settings.company_name instead of the frozen companies.name.
supabase.from('company_settings').select('company_id, company_name'),
// Per-user UI state (nav collapse/fold state), server-rendered so the
// sidebar width is right on first paint, plus the hide-assistant-FAB
// preference (Inställningar → Assistenten). Batched here so it costs no
// extra round-trip on the dashboard critical path.
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
])
// company_id -> current display name for every company the user belongs to.
const nameByCompany = new Map(
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
)
if (!companyRow || !memberRow) {
// Stale cookie pointing to a deleted/inaccessible company.
// Render the empty-state dashboard so user can switch or create a company.
const companyContextValue = {
company: null,
role: null,
companies: (allMemberships || []).filter(m => m.companies).map((m) => {
const c = m.companies as unknown as import('@/types').Company
return {
company: { ...c, name: nameByCompany.get(c.id) || c.name },
role: m.role as CompanyRole,
}
}),
isTeamMember,
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
// If onboarding incomplete, still render the dashboard: the page component
// will show the inline onboarding card instead of the normal dashboard content.
// Use company_name from settings as the display name (companies.name may be stale)
const displayName = settings?.company_name || companyRow.name
// Resolve entity type the same way the report engines and
// getCompanyEntityType do: company_settings is read-primary, companies is the
// canonical fallback, then default to enskild_firma. Mirroring it onto the
// active company keeps the settings rail (useSettingsNavItems, which reads
// context) and the sidebar in agreement on who is an employer. #782
const entityType =
(settings?.entity_type as EntityType) ||
(companyRow.entity_type as EntityType) ||
'enskild_firma'
const paysSalaries = settings?.pays_salaries ?? false
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
// mechanism as paysSalaries: UI gate only, never load-bearing for
// correctness (dimensions plan §2).
const dimensionsEnabled = settings?.dimensions_enabled ?? false
const companyWithName = {
...companyRow,
name: displayName,
entity_type: entityType,
pays_salaries: paysSalaries,
}
const isSandbox = settings?.is_sandbox === true
// Client-driven UI preferences (sidebar collapse + fold state). Read here
// so the shell renders at the right width on first paint; the nav toggles
// flip the data attribute client-side and persist via /api/user/ui-state.
const uiState = (userPrefs?.ui_state ?? {}) as import('@/types').UserUiState
const navCollapsed = uiState.nav_collapsed === true
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Current display name for every company (company_settings.company_name,
// falling back to the frozen companies.name) so non-active switcher rows
// are current too. For the active company this equals `displayName`.
return {
company: { ...c, name: nameByCompany.get(c.id) || c.name },
role: m.role as CompanyRole,
}
}),
isTeamMember,
team,
isSandbox,
capabilities: entitlements.capabilities,
trialEndsAt: entitlements.trialEndsAt,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider
identity={{
displayName: agentProfileIdentity?.display_name ?? null,
avatarId: agentProfileIdentity?.avatar_id ?? null,
isVerified: Boolean(agentProfileIdentity?.verified_at),
}}
>
<CompanyTabSync />
<div
id="dash-shell"
className="min-h-screen bg-frame md:flex md:flex-col"
style={{ '--nav-w': navCollapsed ? '64px' : '248px' } as React.CSSProperties}
>
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
userEmail={user.email ?? null}
initialUiState={uiState}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
<LazyCommandPalette />
<SettingsHotkey />
{settingsModal}
</div>
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
</AgentSheetProvider>
</CompanyProvider>
)
}