feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand (#585)
* feat(sandbox,branding): prod-parity demo with AI gating + accounted rebrand Sandbox now ships with seeded suppliers, supplier invoices, an asset, a verified agent_profile, and pending operations so the demo company exercises every prod surface. Server-side `guardSandbox()` short- circuits any AI or paid-external API call (Bedrock chat/composer, Resend invoice send, Riksbanken FX, VIES, etc.) and the AgentSheet swaps in a SandboxAgentPreview that explains what's gated and offers a register CTA. DashboardContent no longer mounts the NewUserChecklist when the agent is already built, fixing the path that let sandbox users still trigger /onboarding/agent. Visible branding flips from Gnubok to Accounted: new BrandWordmark component (Hedvig Letters Serif 700), new app/icon.png + PWA icons generated from the accounted icon, default appName updated. URLs, header names, API key prefixes, hostnames, and event/cookie/ localStorage keys keep `gnubok` — the rebrand is visual only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): hardcode supplier-invoice arrival numbers in seed get_next_arrival_number is MAX(arrival_number) + 1 against the same table we're about to insert into. Calling it twice before either row lands made both calls return 1, which then violated the (company_id, arrival_number) unique index — POST /api/sandbox/seed 500'd on first sandbox start. The seeded company is brand new in this branch so 1 and 2 are guaranteed unused; hardcoding side-steps the race entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set paid_amount=0 on unpaid supplier invoice PostgREST normalizes the column set across rows in a bulk insert, so the second supplier invoice (Espresso House, status=registered) was being sent with paid_amount=null because the first row (Telia, paid) set it. supplier_invoices.paid_amount is NOT NULL DEFAULT 0; the default only kicks in when the column is *absent* from the payload, not when it's explicitly null. Set it inline to side-step the normalization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): set actor_type=agent_chat on seeded pending_operations pending_operations only allows user-scoped INSERTs via the `pending_operations_chat_insert` policy, which requires actor_type='agent_chat' alongside auth.uid()=user_id + company membership. The seed was inserting with the default actor_type='user', tripping the RLS check. Also lift risk_level from preview_data (where it was unused) onto the row itself, matching the column added in 20260430120000_pending_operations_actor_and_risk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): address PR #585 review feedback Fixes called out by the core-only CI check, Greptile, and the compliance + Swedish-accounting bots: - AGI Programnamn pinned back to 'gnubok' (CI blocker). The XML Skatteverket receives must keep the stable software identifier regardless of the visual rebrand — same rule as the v1 health endpoint's `service: 'gnubok'` literal. - handleCreateAccount in SandboxAgentPreview + ChatEmptyState now wraps signOut() in try/catch so a transient Supabase failure doesn't strand the user on a dead button (greptile P2 × 2). - /api/currency/rate hard-fails on missing companyId instead of conditionally skipping the sandbox guard (greptile P2 / compliance V8.2.1). - topUpSandboxAdditions now delegates to ensureSandboxAgentProfile; the assistant persona lives in exactly one place across the seed, layout backfills, and top-up path (greptile P2 outside-diff / compliance SOC2 CC6.1). - ensureSandboxAgentProfile drops the userId param and sets verified_by_user_id to NULL — synthetic seed data should not attribute verification to a real user (compliance V8.2.1 / GDPR Art. 25(2)). Errors now logged via the structured logger instead of being silently swallowed (V16). - Sandbox seed swaps real-world company names (Telia, Espresso House) for clearly-synthetic Demo-prefixed brands using the 5559... documentation org-number range (compliance A.8.33). Asset cost bumped 24 000 → 35 000 SEK so the demo clears the förbrukningsinventarier threshold and illustrates capitalization unambiguously (swedish-asset-accounting). - Representation pending-operation preview corrected: VAT label fixed from 6% → 12%, and input VAT split between the avdragsgill (2641) and ej-avdragsgill (5811) portions to match swedish-vat / ML 8 kap rules (swedish-vat). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pr-review): seed preview consistency + AGI Programnamn constant Two last review-bot items before merge: - Sandbox seed: the representation pending-operation preview was splitting the 240 SEK café meal 60/180 between 5810 and 5811, which is wrong for a single attendee under the 300 SEK / person avdragsgill cap (ML 8 kap) — the entire amount is fully avdragsgill in that case. Collapse the preview to a single 5810 + 2641 + 2440 entry so it matches the supplier_invoice_items row 1:1 and stops teaching demo users an incorrect bookkeeping pattern. - Hoist the AGI Programnamn 'gnubok' literal into a named constant with a comment pointing to potential future Skatteverket vendor registration (per the swedish-compliance bot's nit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sandbox): avoid BFL duplicate-verification on pending op + fix VAT cap comment Swedish-compliance bot caught two final nits: - The pending operation for the Demokafé representation was using the same supplier_invoice_number as the already-seeded supplier_invoices row (88245). If the sandbox user approved the staged operation, the insert would have created (or attempted) a duplicate verification — BFL 5 kap. requires each affärshändelse be recorded exactly once. Swap the staged operation's invoice number to a distinct value (INKOMMANDE-2026-001) so approval cleanly creates a new row. - The preview comment described the 300 SEK threshold as an "avdragsgill cap". The actual rule (ML 8 kap. 9 §) caps the deductible VAT at 25 % × 300 SEK × antal_personer = 75 SEK per person — the 300 SEK is the tax base, not the total. Math here is correct either way, but the comment now states the correct formula so future seed edits don't propagate the wrong understanding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, ArrowLeft, KeyRound, ExternalLink } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { BrandWordmark } from '@/components/branding/BrandWordmark'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
@@ -376,14 +376,7 @@ function LoginPageContent() {
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto mb-2"
|
||||
priority
|
||||
/>
|
||||
<BrandWordmark size="hero" className="mb-2" />
|
||||
<p className="text-muted-foreground text-sm mt-3">
|
||||
{tAuth('login_subtitle')}
|
||||
</p>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, ArrowLeft, ExternalLink } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { BrandWordmark } from '@/components/branding/BrandWordmark'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { isBankIdEnabled } from '@/lib/auth/bankid'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
@@ -410,14 +410,7 @@ function RegisterPageContent() {
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto mb-2"
|
||||
priority
|
||||
/>
|
||||
<BrandWordmark size="hero" className="mb-2" />
|
||||
<p className="text-muted-foreground text-sm mt-3">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
@@ -700,7 +700,7 @@ export default function ArsredovisningPage() {
|
||||
<strong>Notis om digital inlämning:</strong> Bolagsverket har föreslagit att
|
||||
digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli
|
||||
obligatorisk — beslut och ikraftträdande är ännu inte fastställda. Idag är
|
||||
PDF-inlämning fortfarande godkänd. Gnubok stödjer för närvarande endast
|
||||
PDF-inlämning fortfarande godkänd. Accounted stödjer för närvarande endast
|
||||
PDF-utkast; iXBRL-generering är planerad till en kommande version.
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import ChatSidebar from '@/components/agent/ChatSidebar'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -21,11 +22,31 @@ export default async function ChatLayout({ children }: { children: React.ReactNo
|
||||
// empty conversations list with no Anna to talk to. The home route at /
|
||||
// renders NewUserChecklist for the same state, so we forward there
|
||||
// instead of duplicating the welcome screen here.
|
||||
const { data: agent } = await supabase
|
||||
let { data: agent } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
// Sandbox sessions get a pre-built assistant — backfill if a pre-seed
|
||||
// session is missing it so /chat doesn't bounce back to / in a loop.
|
||||
if (!agent?.verified_at) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (settings?.is_sandbox) {
|
||||
await ensureSandboxAgentProfile(supabase, companyId)
|
||||
const refresh = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
agent = refresh.data
|
||||
}
|
||||
}
|
||||
|
||||
if (!agent?.verified_at) redirect('/')
|
||||
|
||||
const { data: conversations } = await supabase
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
import { CompanyProvider } from '@/contexts/CompanyContext'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import type { EntityType, CompanyRole, Team } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -209,6 +210,23 @@ export default async function DashboardLayout({
|
||||
|
||||
const isSandbox = settings?.is_sandbox === true
|
||||
|
||||
// Backfill a verified agent_profile for sandbox sessions that pre-date the
|
||||
// seed change. Without this an old anonymous session shows the "Bygg din
|
||||
// bokföringsassistent" CTA in three places (dashboard hero, NewUserChecklist
|
||||
// step 4, /chat layout redirect) and the user can still kick off a build
|
||||
// flow that the server now 403s. Best-effort; doesn't block the layout
|
||||
// even if the insert fails.
|
||||
let resolvedAgentIdentity = agentProfileIdentity
|
||||
if (isSandbox && !agentProfileIdentity?.verified_at) {
|
||||
await ensureSandboxAgentProfile(supabase, companyId)
|
||||
const { data: refreshed } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('display_name, avatar_id, verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
resolvedAgentIdentity = refreshed ?? agentProfileIdentity
|
||||
}
|
||||
|
||||
const companyContextValue = {
|
||||
company: companyWithName,
|
||||
role: memberRow.role as CompanyRole,
|
||||
@@ -229,9 +247,9 @@ export default async function DashboardLayout({
|
||||
<CompanyProvider value={companyContextValue}>
|
||||
<AgentSheetProvider
|
||||
identity={{
|
||||
displayName: agentProfileIdentity?.display_name ?? null,
|
||||
avatarId: agentProfileIdentity?.avatar_id ?? null,
|
||||
isVerified: Boolean(agentProfileIdentity?.verified_at),
|
||||
displayName: resolvedAgentIdentity?.display_name ?? null,
|
||||
avatarId: resolvedAgentIdentity?.avatar_id ?? null,
|
||||
isVerified: Boolean(resolvedAgentIdentity?.verified_at),
|
||||
}}
|
||||
>
|
||||
<CompanyTabSync />
|
||||
|
||||
@@ -5,6 +5,7 @@ import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import WelcomeGate from '@/components/onboarding/WelcomeGate'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -122,7 +123,22 @@ export default async function DashboardPage() {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
const agentBuilt = Boolean(agentProfile?.verified_at)
|
||||
// Sandbox sessions that pre-date the agent_profile seeding step would
|
||||
// otherwise still see the "Bygg din bokföringsassistent" hero + the
|
||||
// NewUserChecklist's agent step lit up. Backfill here so the next render
|
||||
// sees a verified profile and treats the sandbox as fully set up.
|
||||
let effectiveAgentVerified = agentProfile?.verified_at ?? null
|
||||
if (settings?.is_sandbox === true && !effectiveAgentVerified) {
|
||||
await ensureSandboxAgentProfile(supabase, companyId)
|
||||
const { data: refreshed } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('verified_at')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
effectiveAgentVerified = refreshed?.verified_at ?? null
|
||||
}
|
||||
|
||||
const agentBuilt = Boolean(effectiveAgentVerified)
|
||||
|
||||
// "Has the company already been used?" Any real business data means we must
|
||||
// NOT hijack the dashboard with the full-screen onboarding gate — existing
|
||||
|
||||
@@ -21,6 +21,17 @@ export default async function AgentOnboardingPage() {
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
if (!companyId) redirect('/onboarding')
|
||||
|
||||
// Sandbox companies ship with a pre-built verified agent_profile — the
|
||||
// build flow on this page would call TIC and the gated composer stream,
|
||||
// both of which 403. Send them back to the dashboard where the demo
|
||||
// assistant is already visible via the sheet preview.
|
||||
const { data: settingsForSandbox } = await supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (settingsForSandbox?.is_sandbox) redirect('/')
|
||||
|
||||
// Trigger the TIC live-fetch + cache before the field-resolving query
|
||||
// below. ensureTicSnapshot is fast on cache-hit (single SELECT) and
|
||||
// best-effort on miss — it never throws. Phase A still runs through the
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { composeAgentProfile } from '@/lib/agent/composer'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
|
||||
const BodySchema = z.object({
|
||||
// Optional override; if absent we use the user's active_company_id.
|
||||
@@ -63,6 +64,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
}
|
||||
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
try {
|
||||
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
|
||||
return NextResponse.json({ data: composed })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getIntent } from '@/lib/agent/intents/registry'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { runChatTurn, friendlyModelError } from '@/lib/agent/chat/run-turn'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
|
||||
// Make sure extensions are loaded — the chat loop dispatches against the
|
||||
// agent tool registry which is populated by the mcp-server extension at load.
|
||||
@@ -105,6 +106,11 @@ export async function POST(request: Request) {
|
||||
.maybeSingle()
|
||||
if (!membership) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
// No Anthropic Bedrock calls in the sandbox — the demo runs entirely on
|
||||
// seed data and the assistant is gated to a "look, don't touch" preview.
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
// onboarding.intake completion signal — once the user has actually
|
||||
// engaged (typed a real reply, not the auto-fired greeting prompt that
|
||||
// mounts the chat), stamp intake_completed_at on the profile so re-entry
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
|
||||
import { gatherComposerInputs, inputsToSourceSignals } from '@/lib/agent/composer/inputs'
|
||||
import { selectAtoms } from '@/lib/agent/composer/atom-selection'
|
||||
@@ -100,6 +101,11 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Not a member of this company' }, { status: 403 })
|
||||
}
|
||||
|
||||
// No live composer run for sandbox companies — they ship with a pre-built
|
||||
// verified agent_profile so the chrome is visible without burning Bedrock.
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { Currency } from '@/types'
|
||||
|
||||
const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
@@ -12,6 +14,16 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await getActiveCompanyId(supabase, user.id)
|
||||
// Refuse the request when no active company resolves rather than letting
|
||||
// a session without one slip past the sandbox guard. Riksbanken's open
|
||||
// API is IP rate-limited; we don't want demo traffic eating that budget.
|
||||
if (!companyId) {
|
||||
return NextResponse.json({ error: 'No active company' }, { status: 400 })
|
||||
}
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const currency = searchParams.get('currency') as Currency | null
|
||||
const dateStr = searchParams.get('date')
|
||||
|
||||
@@ -65,6 +65,15 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
// The sandbox guard issues a company_settings query at the top of the route;
|
||||
// short-circuit it in tests since the queued mock-supabase is shaped for the
|
||||
// route's existing fetch chain, not an extra pre-flight read.
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
guardSandbox: vi.fn().mockResolvedValue(null),
|
||||
isSandboxCompany: vi.fn().mockResolvedValue(false),
|
||||
sandboxBlockedResponse: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/invoices/[id]/send', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -26,6 +27,11 @@ export const POST = withRouteContext(
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ invoiceId: id })
|
||||
|
||||
// The sandbox must never deliver a real email to a real customer — block
|
||||
// the entire send pipeline (PDF render + Resend send + status flip).
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
return errorResponseFromCode('INVOICE_SEND_EMAIL_NOT_CONFIGURED', opLog, { requestId })
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import crypto from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { truncateIp } from '@/lib/api/v1/with-api-v1'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
|
||||
// Anonymous sign-in is enabled in all environments so visitors can try the
|
||||
// product; a per-/24 cap on the seed endpoint keeps a single network from
|
||||
@@ -80,7 +82,11 @@ export async function POST(request: Request) {
|
||||
companyId = newCompanyId as string
|
||||
}
|
||||
|
||||
// Idempotency: if already seeded, return early
|
||||
// Idempotency: if the core seed already ran (company_settings exists), skip
|
||||
// the bulk insert path. We still TOP UP the newer surfaces (agent_profile,
|
||||
// suppliers, asset, pending operations) afterwards so an old sandbox session
|
||||
// — created before those were added to the seed — picks them up on the next
|
||||
// call instead of being stuck without a verified assistant.
|
||||
const { data: existing } = await supabase
|
||||
.from('company_settings')
|
||||
.select('id')
|
||||
@@ -88,7 +94,13 @@ export async function POST(request: Request) {
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ seeded: false })
|
||||
try {
|
||||
await topUpSandboxAdditions(supabase, companyId)
|
||||
return NextResponse.json({ seeded: false, topped_up: true })
|
||||
} catch (err) {
|
||||
log.error('failed to top up sandbox additions', { error: err, userId: user.id, companyId })
|
||||
return NextResponse.json({ seeded: false, topped_up: false })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -581,6 +593,246 @@ export async function POST(request: Request) {
|
||||
|
||||
if (dlError) throw dlError
|
||||
|
||||
// 13. Seed suppliers + one registered supplier invoice + one paid one.
|
||||
// Supplier invoices are arguably the second-most-used surface after
|
||||
// bank transactions; without them the /suppliers and /supplier-invoices
|
||||
// pages render the empty state and the demo loses a big chunk of the
|
||||
// accounts-payable story.
|
||||
// Supplier names use the "Demo" prefix and the documentation-reserved
|
||||
// 5559... org-number range so the seeded rows cannot be confused with
|
||||
// production data should they ever leak into a real environment.
|
||||
const { data: suppliers, error: supError } = await supabase
|
||||
.from('suppliers')
|
||||
.insert([
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
name: 'Demo Telekom AB',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '5559000001',
|
||||
vat_number: 'SE555900000101',
|
||||
email: 'demo+telekom@example.com',
|
||||
bankgiro: '5559-0001',
|
||||
address_line1: 'Demovägen 10',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
country: 'SE',
|
||||
default_payment_terms: 30,
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
name: 'Demokafé AB',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '5559000002',
|
||||
vat_number: 'SE555900000201',
|
||||
bankgiro: '5559-0002',
|
||||
address_line1: 'Demovägen 11',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
country: 'SE',
|
||||
default_payment_terms: 15,
|
||||
},
|
||||
])
|
||||
.select('id, name')
|
||||
|
||||
if (supError) throw supError
|
||||
const supplierMap = Object.fromEntries(suppliers.map(s => [s.name, s.id]))
|
||||
|
||||
// Supplier invoice #1 — Telia, paid 15 days ago (mobile + bredband, 25% VAT).
|
||||
const sevenDaysFromNow = new Date(today)
|
||||
sevenDaysFromNow.setDate(today.getDate() + 7)
|
||||
|
||||
// Hardcode 1 and 2 — get_next_arrival_number is MAX+1 against the same
|
||||
// table we're about to insert into, so calling it twice before the first
|
||||
// insert lands gives the same value for both rows and violates the
|
||||
// (company_id, arrival_number) unique index. The company is brand new
|
||||
// here, so 1 and 2 are guaranteed to be free.
|
||||
const { data: supInvoices, error: supInvError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert([
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
supplier_id: supplierMap['Demo Telekom AB'],
|
||||
arrival_number: 1,
|
||||
supplier_invoice_number: '4711-2026-03',
|
||||
invoice_date: toDateStr(thirtyDaysAgo),
|
||||
due_date: toDateStr(today),
|
||||
received_date: toDateStr(thirtyDaysAgo),
|
||||
status: 'paid',
|
||||
currency: 'SEK',
|
||||
subtotal: 480,
|
||||
vat_amount: 120,
|
||||
total: 600,
|
||||
payment_reference: '47112026031',
|
||||
paid_at: toDateStr(fifteenDaysAgo),
|
||||
paid_amount: 600,
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
supplier_id: supplierMap['Demokafé AB'],
|
||||
arrival_number: 2,
|
||||
supplier_invoice_number: '88245',
|
||||
invoice_date: toDateStr(fiveDaysAgo),
|
||||
due_date: toDateStr(sevenDaysFromNow),
|
||||
received_date: toDateStr(fiveDaysAgo),
|
||||
status: 'registered',
|
||||
currency: 'SEK',
|
||||
subtotal: 240,
|
||||
vat_amount: 28.80,
|
||||
total: 268.80,
|
||||
// Must be set explicitly: PostgREST normalizes columns across
|
||||
// rows in a bulk insert, so omitting paid_amount here while the
|
||||
// first row sets it sends null instead of falling through to the
|
||||
// schema default (0), violating the NOT NULL constraint.
|
||||
paid_amount: 0,
|
||||
},
|
||||
])
|
||||
.select('id, supplier_invoice_number')
|
||||
|
||||
if (supInvError) throw supInvError
|
||||
const supInvoiceMap = Object.fromEntries(
|
||||
supInvoices.map(s => [s.supplier_invoice_number, s.id])
|
||||
)
|
||||
|
||||
// Supplier invoice line items. Note: supplier_invoice_items.vat_rate is
|
||||
// stored as a decimal (0.25 = 25%); invoice_items.vat_rate above uses
|
||||
// integer percent (25). Two different conventions inherited from earlier
|
||||
// migrations — don't try to "fix" it here.
|
||||
const { error: supItemsError } = await supabase
|
||||
.from('supplier_invoice_items')
|
||||
.insert([
|
||||
{
|
||||
supplier_invoice_id: supInvoiceMap['4711-2026-03'],
|
||||
description: 'Mobil + bredband — mars',
|
||||
quantity: 1,
|
||||
unit_price: 480,
|
||||
line_total: 480,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 120,
|
||||
account_number: '6212',
|
||||
},
|
||||
{
|
||||
supplier_invoice_id: supInvoiceMap['88245'],
|
||||
description: 'Kundmöte Demokafé (representation)',
|
||||
quantity: 1,
|
||||
unit_price: 240,
|
||||
line_total: 240,
|
||||
vat_rate: 0.12,
|
||||
vat_amount: 28.80,
|
||||
account_number: '5810',
|
||||
},
|
||||
])
|
||||
|
||||
if (supItemsError) throw supItemsError
|
||||
|
||||
// 14. Add one fully-depreciable asset (laptop) so /assets shows
|
||||
// something other than a Package empty state. Acquired 18 months ago,
|
||||
// 60-month linear depreciation. Cost set above the 2026
|
||||
// förbrukningsinventarier threshold (half prisbasbelopp ≈ 29 600 SEK)
|
||||
// so the demo unambiguously illustrates capitalization rather than
|
||||
// direct expensing.
|
||||
const eighteenMonthsAgo = new Date(today)
|
||||
eighteenMonthsAgo.setMonth(today.getMonth() - 18)
|
||||
const { error: assetError } = await supabase
|
||||
.from('assets')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
name: 'Demo-laptop',
|
||||
category: 'computer',
|
||||
acquisition_date: toDateStr(eighteenMonthsAgo),
|
||||
acquisition_cost: 35000,
|
||||
salvage_value: 0,
|
||||
useful_life_months: 60,
|
||||
depreciation_method: 'linear',
|
||||
bas_asset_account: '1250',
|
||||
bas_accumulated_account: '1259',
|
||||
bas_expense_account: '7831',
|
||||
notes: 'Demo-tillgång — visar planenlig avskrivning över 5 år.',
|
||||
})
|
||||
|
||||
if (assetError) throw assetError
|
||||
|
||||
// 15. Pre-built, verified agent_profile so the assistant chrome (FAB,
|
||||
// /chat surface, agent identity in nav) renders without firing a
|
||||
// composer run. The chat itself is server-gated by guardSandbox().
|
||||
// Delegated to ensureSandboxAgentProfile so the persona lives in one
|
||||
// place (this seed, the dashboard/chat layout backfill, and the seed
|
||||
// top-up path all use the same helper).
|
||||
await ensureSandboxAgentProfile(supabase, companyId)
|
||||
|
||||
// 16. Pre-staged pending_operations so /pending isn't empty.
|
||||
// These are the kind of operation the AI agent would stage; pre-seeded
|
||||
// here so the user can see the approval queue UI (preview, period
|
||||
// status, risk level) without having to invoke the disabled AI.
|
||||
// actor_type='agent_chat' + risk_level on the row itself is required by
|
||||
// pending_operations_chat_insert (the only RLS policy that lets a
|
||||
// user-scoped client INSERT into this table).
|
||||
const { error: pendOpsError } = await supabase
|
||||
.from('pending_operations')
|
||||
.insert([
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
operation_type: 'create_supplier_invoice_from_inbox',
|
||||
status: 'pending',
|
||||
actor_type: 'agent_chat',
|
||||
risk_level: 'low',
|
||||
// Uses a distinct supplier_invoice_number so approving this
|
||||
// pending operation creates a NEW supplier_invoices row instead
|
||||
// of colliding with the Demokafé '88245' already booked above
|
||||
// (BFL 5 kap — each affärshändelse must be recorded exactly once).
|
||||
title: 'Registrera leverantörsfaktura — Demokafé (representation, nytt underlag)',
|
||||
params: {
|
||||
supplier_id: supplierMap['Demokafé AB'],
|
||||
supplier_invoice_number: 'INKOMMANDE-2026-001',
|
||||
invoice_date: toDateStr(fiveDaysAgo),
|
||||
due_date: toDateStr(sevenDaysFromNow),
|
||||
total: 268.80,
|
||||
vat_amount: 28.80,
|
||||
account_number: '5810',
|
||||
},
|
||||
preview_data: {
|
||||
// Representation @ 12% VAT (café meal), 240 SEK excl. VAT for
|
||||
// a single attendee. The avdragsrätt cap is 25% × 300 SEK ×
|
||||
// antal_personer = 75 SEK / person (ML 8 kap. 9 §); since the
|
||||
// VAT here is 28.80 SEK the full amount is deductible and the
|
||||
// cost lands in 5810 — no split needed.
|
||||
preview_lines: [
|
||||
{ account: '5810', description: 'Representation (12% moms, ≤ 75 SEK moms/pers)', debit: 240, credit: 0 },
|
||||
{ account: '2641', description: 'Ingående moms', debit: 28.80, credit: 0 },
|
||||
{ account: '2440', description: 'Leverantörsskulder', debit: 0, credit: 268.80 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
operation_type: 'categorize_transaction',
|
||||
status: 'pending',
|
||||
actor_type: 'agent_chat',
|
||||
risk_level: 'low',
|
||||
title: 'Bokför insättning — bankgiro',
|
||||
params: {
|
||||
account_number: '3001',
|
||||
is_business: true,
|
||||
vat_treatment: 'standard_25',
|
||||
},
|
||||
preview_data: {
|
||||
preview_lines: [
|
||||
{ account: '1930', description: 'Företagskonto', debit: 1200, credit: 0 },
|
||||
{ account: '2611', description: 'Utgående moms 25%', debit: 0, credit: 240 },
|
||||
{ account: '3001', description: 'Försäljning 25% moms', debit: 0, credit: 960 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
if (pendOpsError) throw pendOpsError
|
||||
|
||||
return NextResponse.json({ seeded: true })
|
||||
} catch (err) {
|
||||
log.error('failed to seed sandbox data', { error: err, userId: user.id, companyId })
|
||||
@@ -590,3 +842,17 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent top-up for sandboxes that pre-date the agent_profile addition
|
||||
* to the seed. Re-running the seed on those older sandboxes short-circuits
|
||||
* at the company_settings idempotency check above, so they never get the
|
||||
* agent_profile without this hook. Delegates to ensureSandboxAgentProfile
|
||||
* so the profile data stays in exactly one place.
|
||||
*/
|
||||
async function topUpSandboxAdditions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
await ensureSandboxAgentProfile(supabase, companyId)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { Currency, Transaction } from '@/types'
|
||||
|
||||
export const POST = withRouteContext(
|
||||
@@ -10,6 +11,9 @@ export const POST = withRouteContext(
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
|
||||
@@ -73,6 +73,15 @@ vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||
InvoicePDF: vi.fn().mockReturnValue({}),
|
||||
brandingFromCompanySettings: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
// The sandbox guard reads company_settings.is_sandbox at the top of the
|
||||
// route; the per-table mock supabase below has no row for that lookup so
|
||||
// short-circuit the guard in tests.
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
guardSandbox: vi.fn().mockResolvedValue(null),
|
||||
isSandboxCompany: vi.fn().mockResolvedValue(false),
|
||||
sandboxBlockedResponse: vi.fn(),
|
||||
}))
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
|
||||
@@ -54,6 +54,7 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types'
|
||||
|
||||
const INVOICE_SEND_RESPONSE_COLUMNS =
|
||||
@@ -136,6 +137,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Sandbox demo never sends a real email — guard the whole pipeline
|
||||
// before any number is allocated or PDF is rendered.
|
||||
const blocked = await guardSandbox(ctx.supabase, ctx.companyId!)
|
||||
if (blocked) return blocked
|
||||
|
||||
// Step 1: email service configured?
|
||||
const emailService = getEmailService()
|
||||
if (!emailService.isConfigured()) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { ValidateVatNumberSchema } from '@/lib/api/schemas'
|
||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -16,6 +17,11 @@ export async function POST(request: Request) {
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// VIES is a live external call to the EU Commission — block in the sandbox
|
||||
// so the demo can't generate background traffic against it.
|
||||
const blocked = await guardSandbox(supabase, companyId)
|
||||
if (blocked) return blocked
|
||||
|
||||
const result = await validateBody(request, ValidateVatNumberSchema)
|
||||
if (!result.success) return result.response
|
||||
const { vat_number, customer_id } = result.data
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { createCompanyFromOnboarding } from '@/lib/company/actions'
|
||||
@@ -238,14 +237,9 @@ function NewCompanyContent() {
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={30}
|
||||
height={30}
|
||||
className="invert opacity-90"
|
||||
/>
|
||||
<span className="font-display text-base tracking-tight">{branding.appName.toLowerCase()}</span>
|
||||
<span className="font-display text-base tracking-tight" style={{ fontWeight: 700 }}>
|
||||
{branding.appName.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STEP_INFO.map((_, i) => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
|
||||
import { CHANGELOG_MD } from '@/lib/docs/content/changelog'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Changelog · gnubok API',
|
||||
description: 'Reverse-chronological release notes for the gnubok REST API.',
|
||||
title: 'Changelog · accounted API',
|
||||
description: 'Reverse-chronological release notes for the accounted REST API.',
|
||||
}
|
||||
|
||||
export default function DocsApiChangelogPage() {
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
const entry = findRecipe(slug)
|
||||
if (!entry) return { title: 'Not found' }
|
||||
return {
|
||||
title: `${entry.title} · gnubok API cookbook`,
|
||||
title: `${entry.title} · accounted API cookbook`,
|
||||
description: entry.description,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
|
||||
import { buildErrorReferenceMd } from '@/lib/docs/content/errors'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Errors · gnubok API',
|
||||
description: 'Every stable error code returned by the gnubok REST API, with HTTP status, description, and remediation.',
|
||||
title: 'Errors · accounted API',
|
||||
description: 'Every stable error code returned by the accounted REST API, with HTTP status, description, and remediation.',
|
||||
}
|
||||
|
||||
export default function DocsApiErrorsPage() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LANDING_MD } from '@/lib/docs/content/landing'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'gnubok API · Documentation',
|
||||
title: 'accounted API · Documentation',
|
||||
description: 'Swedish double-entry bookkeeping as a public REST API for agents and integrations.',
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
||||
const page = buildResourcePages().find((p) => p.slug === slug)
|
||||
if (!page) return { title: 'Not found' }
|
||||
return {
|
||||
title: `${page.label} · gnubok API`,
|
||||
title: `${page.label} · accounted API`,
|
||||
description: page.description,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
|
||||
import { buildReferenceOverviewMd } from '@/lib/docs/content/reference'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'API reference · gnubok API',
|
||||
description: 'Every endpoint exposed by the gnubok REST API, grouped by resource.',
|
||||
title: 'API reference · accounted API',
|
||||
description: 'Every endpoint exposed by the accounted REST API, grouped by resource.',
|
||||
}
|
||||
|
||||
export default function DocsApiReferencePage() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
|
||||
import { VERSIONING_MD } from '@/lib/docs/content/versioning'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Versioning · gnubok API',
|
||||
title: 'Versioning · accounted API',
|
||||
description: 'How API versions are pinned, upgraded, and deprecated. Plus idempotency, dry-run, and strict-mode write semantics.',
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { DocsMarkdown } from '@/lib/docs/markdown'
|
||||
import { WEBHOOKS_MD } from '@/lib/docs/content/webhooks'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Webhooks · gnubok API',
|
||||
description: 'Receive HMAC-signed POST notifications when state changes in gnubok. Includes signature verification samples in Node.js and Python.',
|
||||
title: 'Webhooks · accounted API',
|
||||
description: 'Receive HMAC-signed POST notifications when state changes in accounted. Includes signature verification samples in Node.js and Python.',
|
||||
}
|
||||
|
||||
export default function DocsApiWebhooksPage() {
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -3,7 +3,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
@@ -168,14 +167,9 @@ export default function InvitePage() {
|
||||
</div>
|
||||
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
|
||||
<div className="flex items-center gap-2.5 mb-5 md:mb-6">
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={30}
|
||||
height={30}
|
||||
className="invert opacity-90"
|
||||
/>
|
||||
<span className="font-display text-base tracking-tight">{branding.appName.toLowerCase()}</span>
|
||||
<span className="font-display text-base tracking-tight" style={{ fontWeight: 700 }}>
|
||||
{branding.appName.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
|
||||
|
||||
@@ -29,6 +29,9 @@ const hedvigSerif = Hedvig_Letters_Serif({
|
||||
variable: "--font-hedvig-serif",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
// Hedvig Letters Serif on Google Fonts only ships 400; the brand wordmark
|
||||
// requests 700, which browsers synthesize from this file. Keep the load
|
||||
// single-file to stay within the existing display-font budget.
|
||||
weight: "400",
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Receipt, ArrowLeftRight, BookOpen, BarChart3 } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { BrandWordmark } from '@/components/branding/BrandWordmark'
|
||||
|
||||
const branding = getBranding()
|
||||
|
||||
@@ -81,14 +81,7 @@ export default function SandboxPage() {
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto mb-2"
|
||||
priority
|
||||
/>
|
||||
<BrandWordmark size="hero" className="mb-2" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
@@ -116,14 +109,7 @@ export default function SandboxPage() {
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up">
|
||||
<div className="text-center mb-10">
|
||||
<Image
|
||||
src={branding.logoPath}
|
||||
alt={branding.appName}
|
||||
width={240}
|
||||
height={240}
|
||||
className="mx-auto mb-2"
|
||||
priority
|
||||
/>
|
||||
<BrandWordmark size="hero" className="mb-2" />
|
||||
<h1 className="text-xl font-medium tracking-tight mt-3">
|
||||
Testa {branding.appName.toLowerCase()} utan att registrera dig
|
||||
</h1>
|
||||
@@ -133,23 +119,11 @@ export default function SandboxPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
{/* Feature highlights */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
{[
|
||||
{ icon: Receipt, label: 'Fakturor' },
|
||||
{ icon: ArrowLeftRight, label: 'Transaktioner' },
|
||||
{ icon: BookOpen, label: 'Bokföring' },
|
||||
{ icon: BarChart3, label: 'Rapporter' },
|
||||
].map(({ icon: Icon, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-2.5 rounded-lg bg-muted/40 px-3 py-2.5"
|
||||
>
|
||||
<Icon className="h-4 w-4 text-primary/70 flex-shrink-0" />
|
||||
<span className="text-sm text-foreground/80">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-6 rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground">
|
||||
AI-assistenten och externa tjänster (e-post, bankuppkoppling,
|
||||
valutakurser, Skatteverket) är avstängda i sandlådan — de
|
||||
kräver ett riktigt konto.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
|
||||
@@ -725,7 +725,7 @@ function MessageBubble({
|
||||
key={s.tool_use_id}
|
||||
className="rounded-lg border border-border bg-card px-4 py-3 text-sm text-muted-foreground"
|
||||
>
|
||||
Förslag stageat men ingen operation-id mottagen. Granska i gnubok under <em>Förslag</em>.
|
||||
Förslag stageat men ingen operation-id mottagen. Granska i accounted under <em>Förslag</em>.
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { X, Expand } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import SandboxAgentPreview from './SandboxAgentPreview'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
|
||||
// Undimmed non-modal side sheet — sits above the page on a hairline border +
|
||||
// shadow, but the page underneath stays fully interactive. Plan §3b.
|
||||
@@ -31,6 +33,8 @@ export default function AgentSheet({
|
||||
}: Props) {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const { identity } = useAgentSheet()
|
||||
const companyCtx = useCompanyOptional()
|
||||
const isSandbox = companyCtx?.isSandbox ?? false
|
||||
const agentName = identity.displayName?.trim() || null
|
||||
const sheetTitle = intentToTitle(intentId, agentName)
|
||||
|
||||
@@ -60,7 +64,7 @@ export default function AgentSheet({
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName ?? 'Assistent'} />
|
||||
<h2 className="font-display text-lg tracking-tight truncate">{sheetTitle}</h2>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{conversationId && (
|
||||
{conversationId && !isSandbox && (
|
||||
<Link
|
||||
href={`/chat/${conversationId}`}
|
||||
onClick={onClose}
|
||||
@@ -81,13 +85,17 @@ export default function AgentSheet({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
intentArgs={intentArgs}
|
||||
contextRef={contextRef}
|
||||
seedUserMessage={seedUserMessage}
|
||||
onConversationIdChange={(id) => setConversationId(id)}
|
||||
/>
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
intentArgs={intentArgs}
|
||||
contextRef={contextRef}
|
||||
seedUserMessage={seedUserMessage}
|
||||
onConversationIdChange={(id) => setConversationId(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import AgentChat, { normalizeStoredMessages } from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import SandboxAgentPreview from './SandboxAgentPreview'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
|
||||
interface Props {
|
||||
conversationId: string
|
||||
@@ -26,6 +28,9 @@ export default function ChatConversationView({
|
||||
}: Props) {
|
||||
const initialMessages = useMemo(() => normalizeStoredMessages(rawMessages), [rawMessages])
|
||||
const { identity } = useAgentSheet()
|
||||
const companyCtx = useCompanyOptional()
|
||||
const isSandbox = companyCtx?.isSandbox ?? false
|
||||
const agentName = identity.displayName?.trim() || null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,14 +57,18 @@ export default function ChatConversationView({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
contextRef={contextRef ?? undefined}
|
||||
initialConversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
contextRef={contextRef ?? undefined}
|
||||
initialConversationId={conversationId}
|
||||
initialMessages={initialMessages}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
import { ArrowUpRight, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
// Tiny client component for /chat empty state. Reads the agent identity from
|
||||
// the provider so it can show the user's chosen avatar + name above the
|
||||
@@ -31,8 +34,46 @@ const SUGGESTIONS: { label: string; prompt: string }[] = [
|
||||
|
||||
export default function ChatEmptyState() {
|
||||
const { identity } = useAgentSheet()
|
||||
const companyCtx = useCompanyOptional()
|
||||
const router = useRouter()
|
||||
const isSandbox = companyCtx?.isSandbox ?? false
|
||||
const name = identity.displayName?.trim() || 'din assistent'
|
||||
|
||||
if (isSandbox) {
|
||||
const handleCreateAccount = async () => {
|
||||
const supabase = createClient()
|
||||
// Sign-out is best-effort — navigate even if Supabase is unreachable
|
||||
// so the button never looks dead.
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} catch {
|
||||
// Intentionally swallowed.
|
||||
}
|
||||
router.push('/register')
|
||||
}
|
||||
return (
|
||||
<div className="hidden md:flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<AgentAvatar avatarId={identity.avatarId} size="lg" alt={name} className="mb-5" />
|
||||
<h1 className="font-display text-2xl tracking-tight mb-2">Fråga {name}</h1>
|
||||
<div className="rounded-lg border border-border bg-secondary/40 px-5 py-4 max-w-md mb-6 text-left">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Avstängd i sandlådan
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
AI-assistenten använder en betald molntjänst och är därför
|
||||
inaktiverad här. I den fullständiga produkten kan {name} kategorisera
|
||||
transaktioner, granska leverantörsfakturor och svara på frågor om
|
||||
din bokföring.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={handleCreateAccount}>
|
||||
Skapa konto för att använda {name}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Hidden on mobile — the sidebar IS the page when no conversation is open.
|
||||
// On desktop, fills the right pane with a centered prompt.
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import SandboxAgentPreview from './SandboxAgentPreview'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
|
||||
// Phase C entry surface. Lands here from ReviewCard's "kör" after Phase B
|
||||
// verify succeeds. Renders AgentChat in fresh-start mode — no
|
||||
@@ -17,6 +19,8 @@ import { useAgentSheet } from './AgentSheetProvider'
|
||||
export default function ChatIntakeStarter() {
|
||||
const router = useRouter()
|
||||
const { identity } = useAgentSheet()
|
||||
const companyCtx = useCompanyOptional()
|
||||
const isSandbox = companyCtx?.isSandbox ?? false
|
||||
const agentName = identity.displayName?.trim() || 'Din assistent'
|
||||
// Lock the swap to the first id we see — defensive guard against the
|
||||
// AgentChat callback firing twice during React 19 Strict Mode reruns.
|
||||
@@ -29,28 +33,34 @@ export default function ChatIntakeStarter() {
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName} är redo</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.
|
||||
{isSandbox
|
||||
? 'Förhandsvisning — den verkliga konversationen kräver ett konto.'
|
||||
: 'Några frågor för att lära känna din verksamhet — svara i din egen takt, du kan avsluta när du vill.'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId="onboarding.intake"
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the greeting to finish streaming AND persist before
|
||||
// swapping the URL. Swapping on the early `conversation` event
|
||||
// unmounts AgentChat mid-stream, so the greeting is never saved
|
||||
// and /chat/[id] hydrates empty — the bug where the chat lands
|
||||
// blank and only shows the intro on a later visit.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId="onboarding.intake"
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the greeting to finish streaming AND persist before
|
||||
// swapping the URL. Swapping on the early `conversation` event
|
||||
// unmounts AgentChat mid-stream, so the greeting is never saved
|
||||
// and /chat/[id] hydrates empty — the bug where the chat lands
|
||||
// blank and only shows the intro on a later visit.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import AgentChat from './AgentChat'
|
||||
import AgentAvatar from './AgentAvatar'
|
||||
import SandboxAgentPreview from './SandboxAgentPreview'
|
||||
import { useAgentSheet } from './AgentSheetProvider'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
|
||||
// Inline starter used by suggestion chips and ⌘K. Mirrors ChatIntakeStarter
|
||||
// but accepts any intent + seed so we don't fork the intake-specific
|
||||
@@ -19,6 +21,8 @@ export default function ChatNewStarter({
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { identity } = useAgentSheet()
|
||||
const companyCtx = useCompanyOptional()
|
||||
const isSandbox = companyCtx?.isSandbox ?? false
|
||||
const agentName = identity.displayName?.trim() || 'Din assistent'
|
||||
const [swapped, setSwapped] = useState(false)
|
||||
|
||||
@@ -28,26 +32,32 @@ export default function ChatNewStarter({
|
||||
<AgentAvatar avatarId={identity.avatarId} size="sm" alt={agentName} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-lg tracking-tight truncate">{agentName}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate">Ny konversation</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{isSandbox ? 'Förhandsvisning — avstängd i sandlådan' : 'Ny konversation'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
seedUserMessage={seedUserMessage}
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the first turn to finish before swapping the URL —
|
||||
// otherwise the unmount aborts the in-flight stream and
|
||||
// /chat/[id] hydrates with only the user message.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{isSandbox ? (
|
||||
<SandboxAgentPreview agentName={agentName} />
|
||||
) : (
|
||||
<AgentChat
|
||||
intentId={intentId}
|
||||
seedUserMessage={seedUserMessage}
|
||||
initialMessages={[]}
|
||||
initialConversationId={null}
|
||||
onFirstTurnComplete={(id) => {
|
||||
// Wait for the first turn to finish before swapping the URL —
|
||||
// otherwise the unmount aborts the in-flight stream and
|
||||
// /chat/[id] hydrates with only the user message.
|
||||
if (swapped) return
|
||||
setSwapped(true)
|
||||
router.replace(`/chat/${id}`)
|
||||
}}
|
||||
scrollerClassName="px-6 py-8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Sparkles, ArrowRight } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* Stand-in for AgentChat in the sandbox. The real chat surface POSTs to
|
||||
* /api/agent/invoke which is server-gated by guardSandbox(), so the input
|
||||
* would just produce a 403. Instead of showing that as a raw error, we
|
||||
* render a brief description of what the assistant does in prod and a
|
||||
* single "Skapa konto" CTA. Same chrome (header) as the real chat — only
|
||||
* the body swaps out.
|
||||
*
|
||||
* Mirrors the look of the empty-state but with an explanation block so the
|
||||
* sandbox user understands what they're seeing without typing into a
|
||||
* dead-end input.
|
||||
*/
|
||||
export default function SandboxAgentPreview({
|
||||
agentName,
|
||||
}: {
|
||||
agentName: string | null
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const name = agentName?.trim() || 'din assistent'
|
||||
|
||||
async function handleCreateAccount() {
|
||||
const supabase = createClient()
|
||||
// Sign-out is best-effort — a transient Supabase failure shouldn't
|
||||
// strand the user on a dead button; navigate to /register either way
|
||||
// and let the registration flow re-init auth state.
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} catch {
|
||||
// Intentionally swallowed — see comment above.
|
||||
}
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto px-5 py-8">
|
||||
<div className="mx-auto max-w-md space-y-6">
|
||||
<div className="rounded-lg border border-border bg-secondary/40 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Förhandsvisning i sandlådan
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{name} är en specialiserad bokföringsassistent som kan
|
||||
kategorisera transaktioner, granska leverantörsfakturor och
|
||||
svara på frågor om din bokföring — kalibrerad mot dina
|
||||
kontoplaner, verksamhet och svensk skattelagstiftning.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">
|
||||
I sandlådan är AI-funktionerna avstängda eftersom de använder
|
||||
externa AI-tjänster som kostar pengar att köra. Skapa ett
|
||||
konto för att aktivera assistenten på riktigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Föreslår bokföring</span>{' '}
|
||||
för oklassificerade transaktioner — du godkänner i ett klick.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Förklarar momsrutor</span>,
|
||||
årets resultat och vad som driver KPI:erna.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-foreground mt-0.5">·</span>
|
||||
<span>
|
||||
<span className="text-foreground">Granskar verifikat</span>{' '}
|
||||
och föreslår rättningar enligt BFL och K2.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={handleCreateAccount}
|
||||
>
|
||||
Skapa konto för att använda {name}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
Sandlådedata raderas efter 24 timmar.{' '}
|
||||
<Link href="/register" className="underline underline-offset-2 hover:text-foreground">
|
||||
Skapa konto
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
|
||||
interface BrandWordmarkProps {
|
||||
/**
|
||||
* Visual size. `'hero'` is for landing/auth/onboarding hero slots (~the
|
||||
* same vertical weight as the old 240px logo image). `'inline'` matches
|
||||
* the old 30px image used in top-left nav contexts.
|
||||
*/
|
||||
size?: 'hero' | 'inline'
|
||||
/**
|
||||
* Force lowercase rendering. Defaults to true to match the existing
|
||||
* font-display + `.toLowerCase()` pattern used elsewhere in the app.
|
||||
*/
|
||||
lowercase?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-only wordmark used in place of the legacy logo image on auth /
|
||||
* onboarding / sandbox / invite surfaces. Renders the active brand's
|
||||
* `appName` in Hedvig Letters Serif at weight 700 — the display font is
|
||||
* single-weight on Google Fonts so 700 ends up synthetically bolded, but
|
||||
* that matches the requested aesthetic.
|
||||
*/
|
||||
export function BrandWordmark({
|
||||
size = 'hero',
|
||||
lowercase = true,
|
||||
className,
|
||||
}: BrandWordmarkProps) {
|
||||
const branding = getBranding()
|
||||
const name = lowercase ? branding.appName.toLowerCase() : branding.appName
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-display tracking-tight inline-block',
|
||||
size === 'hero' ? 'text-5xl md:text-6xl' : 'text-base',
|
||||
className,
|
||||
)}
|
||||
style={{ fontWeight: 700 }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,18 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
const [showAllAlerts, setShowAllAlerts] = useState(false)
|
||||
const t = useTranslations('dashboard')
|
||||
|
||||
const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport
|
||||
// The setup gate exists to nudge brand-new users into a data-import step
|
||||
// before they hit the dashboard. Once the assistant is built we treat the
|
||||
// user as past that phase — they've already committed to using the tool —
|
||||
// and let the dashboard render normally. This also keeps the sandbox
|
||||
// (which ships with a pre-built assistant + seeded data but no bank
|
||||
// connection / SIE import) from showing a checklist that re-links to
|
||||
// /onboarding/agent.
|
||||
const needsSetup =
|
||||
!agentBuilt &&
|
||||
onboardingProgress &&
|
||||
!onboardingProgress.hasBankConnected &&
|
||||
!onboardingProgress.hasSIEImport
|
||||
const [setupGateActive, setSetupGateActive] = useState(!!needsSetup)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,7 +93,10 @@ export default function DashboardContent({ companyId, summary, onboardingProgres
|
||||
if (setupGateActive) {
|
||||
return (
|
||||
<NewUserChecklist
|
||||
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
||||
hasBankConnected={!!onboardingProgress?.hasBankConnected}
|
||||
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
||||
hasAgentBuilt={agentBuilt}
|
||||
onFreshStart={() => {
|
||||
localStorage.setItem(setupFreshStartKey(companyId), 'true')
|
||||
setSetupGateActive(false)
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SandboxBanner() {
|
||||
return (
|
||||
<div className="relative z-50 flex items-center justify-center gap-x-3 gap-y-1 bg-warning px-10 py-2 text-sm text-warning-foreground sm:px-4 flex-wrap">
|
||||
<span className="font-medium text-center text-xs sm:text-sm">
|
||||
Sandlådemiljö — data raderas efter 24h
|
||||
Sandlådemiljö — AI och externa tjänster är avstängda. Data raderas efter 24h.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCreateAccount}
|
||||
|
||||
@@ -30,8 +30,8 @@ export function DocsLayout({ currentPath, children }: DocsLayoutProps) {
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link href="/docs/api" className="font-display text-xl tracking-tight">
|
||||
gnubok <span className="text-muted-foreground">/ docs</span>
|
||||
<Link href="/docs/api" className="font-display text-xl tracking-tight" style={{ fontWeight: 700 }}>
|
||||
accounted <span className="text-muted-foreground" style={{ fontWeight: 400 }}>/ docs</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link href="/docs/api/reference" className="text-foreground/80 hover:text-foreground transition-colors">
|
||||
@@ -96,7 +96,7 @@ export function DocsLayout({ currentPath, children }: DocsLayoutProps) {
|
||||
|
||||
<footer className="border-t border-border mt-20">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 text-sm text-muted-foreground flex items-center justify-between">
|
||||
<span>gnubok REST API · Swedish bookkeeping for agents</span>
|
||||
<span>accounted REST API · Swedish bookkeeping for agents</span>
|
||||
<span className="font-mono text-xs">AGPL-3.0-or-later</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1156,7 +1156,7 @@ function OnboardingCard({
|
||||
{
|
||||
done: hasAnyItem,
|
||||
title: 'Ladda upp eller maila in ett underlag',
|
||||
hint: 'gnubok tolkar fakturan eller kvittot åt dig och fyller i fält automatiskt.',
|
||||
hint: 'accounted tolkar fakturan eller kvittot åt dig och fyller i fält automatiskt.',
|
||||
},
|
||||
{
|
||||
done: hasResolvedItem,
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function SendInvoiceDialog({
|
||||
}: SendInvoiceDialogProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company } = useCompany()
|
||||
const { company, isSandbox } = useCompany()
|
||||
const t = useTranslations('invoice_send_dialog')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -209,6 +209,13 @@ export default function SendInvoiceDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{isSandbox && mode === 'email' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
E-postutskick är avstängt i sandlådan. Använd istället
|
||||
«Markera som skickad» för att testa det resterande
|
||||
flödet.
|
||||
</div>
|
||||
)}
|
||||
{showJournalPreview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -251,8 +258,9 @@ export default function SendInvoiceDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || !isInitialized}
|
||||
disabled={isSubmitting || !isInitialized || (isSandbox && mode === 'email')}
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
title={isSandbox && mode === 'email' ? 'E-postutskick är avstängt i sandlådan' : undefined}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -550,7 +550,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka AGI direkt från {`gnubok`}.
|
||||
Anslut till Skatteverket med BankID för att skicka AGI direkt från accounted.
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<Button onClick={handleConnect}>
|
||||
|
||||
@@ -38,17 +38,17 @@ describe('branding service', () => {
|
||||
registerBrandingService({})
|
||||
})
|
||||
|
||||
it('returns gnubok defaults when nothing is overridden', async () => {
|
||||
it('returns accounted defaults when nothing is overridden', async () => {
|
||||
const { getBranding } = await import('../service')
|
||||
const b = getBranding()
|
||||
expect(b.appName).toBe('Gnubok')
|
||||
expect(b.appName).toBe('Accounted')
|
||||
expect(b.appDescription).toBe('Ekonomihantering')
|
||||
expect(b.legalEntity).toBe('Arcim')
|
||||
expect(b.supportEmail).toBe('support@gnubok.se')
|
||||
expect(b.privacyEmail).toBe('privacy@gnubok.se')
|
||||
expect(b.securityEmail).toBe('security@arcim.io')
|
||||
expect(b.authEmailFrom).toBe('noreply@gnubok.se')
|
||||
expect(b.logoPath).toBe('/gnubokiceon-removebg-preview.png')
|
||||
expect(b.logoPath).toBe('/accounted-icon.png')
|
||||
expect(b.faviconPath).toBe('/favicon.ico')
|
||||
expect(b.appleTouchIconPath).toBe('/icons/icon-192.png')
|
||||
expect(b.pwaIconBasePath).toBe('/icons')
|
||||
@@ -135,7 +135,7 @@ describe('branding service', () => {
|
||||
it('empty string env var does not override', async () => {
|
||||
process.env.NEXT_PUBLIC_BRANDING_APP_NAME = ''
|
||||
const { getBranding } = await import('../service')
|
||||
expect(getBranding().appName).toBe('Gnubok')
|
||||
expect(getBranding().appName).toBe('Accounted')
|
||||
})
|
||||
|
||||
it('clearing extension override returns to env/default resolution', async () => {
|
||||
@@ -143,6 +143,6 @@ describe('branding service', () => {
|
||||
registerBrandingService({ appName: 'Holdio' })
|
||||
expect(getBranding().appName).toBe('Holdio')
|
||||
registerBrandingService({})
|
||||
expect(getBranding().appName).toBe('Gnubok')
|
||||
expect(getBranding().appName).toBe('Accounted')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,15 +58,20 @@ export interface BrandingConfig {
|
||||
}
|
||||
|
||||
const DEFAULT_BRANDING: BrandingConfig = {
|
||||
appName: 'Gnubok',
|
||||
appName: 'Accounted',
|
||||
appDescription: 'Ekonomihantering',
|
||||
legalEntity: 'Arcim',
|
||||
// Emails and URLs intentionally keep the gnubok hostname — the rebrand is
|
||||
// visual only; we don't churn the support inbox or app domain alongside it.
|
||||
supportEmail: 'support@gnubok.se',
|
||||
privacyEmail: 'privacy@gnubok.se',
|
||||
securityEmail: 'security@arcim.io',
|
||||
authEmailFrom: 'noreply@gnubok.se',
|
||||
appUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se',
|
||||
logoPath: '/gnubokiceon-removebg-preview.png',
|
||||
// The visible brand mark now renders as text via <BrandWordmark>; this
|
||||
// image path is kept as a fallback for any surface still using <Image>
|
||||
// (e.g. PWA-style metadata that demands a concrete file).
|
||||
logoPath: '/accounted-icon.png',
|
||||
faviconPath: '/favicon.ico',
|
||||
appleTouchIconPath: '/icons/icon-192.png',
|
||||
pwaIconBasePath: '/icons',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { API_V1_VERSION } from '@/lib/api/v1/version'
|
||||
|
||||
export const LANDING_MD = `# gnubok API
|
||||
export const LANDING_MD = `# accounted API
|
||||
|
||||
> Swedish double-entry bookkeeping as a public REST API for agents and integrations. API version \`${API_V1_VERSION}\`.
|
||||
|
||||
The gnubok API lets you do anything the dashboard can do — create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write.
|
||||
The accounted API lets you do anything the dashboard can do — create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write.
|
||||
|
||||
If you've used [Stripe's API](https://docs.stripe.com/api), the shape will feel familiar — bearer-token auth, dated API versions, webhook signature verification, idempotency keys. The accounting concepts are Swedish (BAS chart, BFL retention, K2/K3, momsdeklaration) but the surface is built for the same kind of integrator.
|
||||
|
||||
@@ -17,7 +17,7 @@ curl https://gnubok.app/api/v1/companies \\
|
||||
-H "Authorization: Bearer gnubok_sk_live_..."
|
||||
\`\`\`
|
||||
|
||||
Create keys in the gnubok dashboard at **/settings/api**. Two key prefixes are available:
|
||||
Create keys in the accounted dashboard at **/settings/api**. Two key prefixes are available:
|
||||
|
||||
- \`gnubok_sk_live_*\` — hits real customer data. Use in production.
|
||||
- \`gnubok_sk_test_*\` — bound to deterministic sandbox companies. Safe for evals, demos, and agent learning. Same surface, different blast radius.
|
||||
@@ -105,5 +105,5 @@ For LLM-based agents:
|
||||
- **[\`/llms.txt\`](/llms.txt)** — concise agent-discovery index.
|
||||
- **[\`/llms-full.txt\`](/llms-full.txt)** — full docs concatenated for ingestion.
|
||||
- **[\`/api/v1/openapi.json\`](/api/v1/openapi.json)** — machine-readable OpenAPI 3.1 spec.
|
||||
- **[\`/.well-known/skills/index.json\`](/.well-known/skills/index.json)** — gnubok-specific skill catalogue.
|
||||
- **[\`/.well-known/skills/index.json\`](/.well-known/skills/index.json)** — accounted-specific skill catalogue.
|
||||
`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { decryptPersonnummer } from '../personnummer'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
|
||||
/**
|
||||
* AGI XML generator — Arbetsgivardeklaration på individnivå.
|
||||
@@ -49,6 +48,13 @@ import { getBranding } from '@/lib/branding/service'
|
||||
const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
|
||||
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
|
||||
|
||||
// Programnamn — software identifier embedded in every AGI submission.
|
||||
// Free-text per Skatteverket's schema (no vendor registry), but kept stable
|
||||
// across visual rebrands so the value the tax authority sees never churns.
|
||||
// Bump only if Skatteverket ever introduces a formal vendor registration
|
||||
// and 'accounted' is the registered name there.
|
||||
const AGI_PROGRAMNAMN = 'gnubok'
|
||||
|
||||
/**
|
||||
* One absence event for AGI Frånvarouppgift emission. Loaded from
|
||||
* salary_absence_days (per-day records). Sick days are NOT included — they
|
||||
@@ -368,7 +374,7 @@ export function generateAGIXml(
|
||||
|
||||
// ── Avsandare (komponent namespace) ──────────────────────────
|
||||
lines.push(' <gem:Avsandare>')
|
||||
lines.push(` <gem:Programnamn>${escapeXml(getBranding().appName.toLowerCase())}</gem:Programnamn>`)
|
||||
lines.push(` <gem:Programnamn>${AGI_PROGRAMNAMN}</gem:Programnamn>`)
|
||||
lines.push(` <gem:Organisationsnummer>${orgIdentitet}</gem:Organisationsnummer>`)
|
||||
lines.push(' <gem:TekniskKontaktperson>')
|
||||
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { isSandboxCompany, sandboxBlockedResponse, guardSandbox } from '../guard'
|
||||
|
||||
function mockSupabase(isSandboxValue: boolean | null) {
|
||||
const maybeSingle = vi.fn().mockResolvedValue({
|
||||
data: isSandboxValue === null ? null : { is_sandbox: isSandboxValue },
|
||||
})
|
||||
const eq = vi.fn(() => ({ maybeSingle }))
|
||||
const select = vi.fn(() => ({ eq }))
|
||||
const from = vi.fn(() => ({ select }))
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { from } as any
|
||||
}
|
||||
|
||||
describe('guardSandbox', () => {
|
||||
it('returns null for non-sandbox companies so the route proceeds', async () => {
|
||||
const supabase = mockSupabase(false)
|
||||
const result = await guardSandbox(supabase, '00000000-0000-0000-0000-000000000001')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no company_settings row exists', async () => {
|
||||
const supabase = mockSupabase(null)
|
||||
const result = await guardSandbox(supabase, '00000000-0000-0000-0000-000000000001')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the 403 NextResponse for sandbox companies', async () => {
|
||||
const supabase = mockSupabase(true)
|
||||
const result = await guardSandbox(supabase, '00000000-0000-0000-0000-000000000001')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.status).toBe(403)
|
||||
const body = await result!.json()
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
expect(body.error).toMatch(/sandlådan/i)
|
||||
expect(body.error_en).toMatch(/sandbox/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSandboxCompany', () => {
|
||||
it('returns false when is_sandbox is missing', async () => {
|
||||
const supabase = mockSupabase(null)
|
||||
expect(await isSandboxCompany(supabase, 'cid')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true only when is_sandbox is exactly true', async () => {
|
||||
expect(await isSandboxCompany(mockSupabase(true), 'cid')).toBe(true)
|
||||
expect(await isSandboxCompany(mockSupabase(false), 'cid')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandboxBlockedResponse', () => {
|
||||
it('returns a 403 with sandbox_blocked envelope', async () => {
|
||||
const res = sandboxBlockedResponse()
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.sandbox_blocked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('sandbox:ensure-agent')
|
||||
|
||||
/**
|
||||
* Backfill a verified agent_profile for sandbox companies. Single source of
|
||||
* truth for the sandbox assistant's persona (name, avatar, atoms, summary) —
|
||||
* the seed route, dashboard layout, dashboard page, and chat layout all call
|
||||
* through here so the profile data lives in exactly one place.
|
||||
*
|
||||
* `verified_by_user_id` is intentionally NULL: the row is synthetic seed
|
||||
* data, not a real user-driven verification. Attributing it to the calling
|
||||
* user would pollute the audit trail (and conflate consent on the GDPR
|
||||
* Art. 25(2) privacy-by-default surface).
|
||||
*
|
||||
* Best-effort: any error is logged and swallowed so the caller continues.
|
||||
* Worst case the user sees the pre-seed UI on this request; the next
|
||||
* request retries.
|
||||
*
|
||||
* Idempotent — the UNIQUE constraint on company_id makes the insert a no-op
|
||||
* once a profile exists.
|
||||
*/
|
||||
export async function ensureSandboxAgentProfile(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: existing } = await supabase
|
||||
.from('agent_profiles')
|
||||
.select('id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (existing) return
|
||||
|
||||
const { error } = await supabase.from('agent_profiles').insert({
|
||||
company_id: companyId,
|
||||
display_name: 'Anna',
|
||||
avatar_id: 'notionists-3',
|
||||
horizontal_atoms: [
|
||||
'horizontal/swedish-vat',
|
||||
'horizontal/swedish-accounting-compliance',
|
||||
],
|
||||
vertical_atoms: ['vertical/consulting'],
|
||||
modifier_atoms: [],
|
||||
profile_summary:
|
||||
'Du är Anna, en revisorsassistent för en svensk enskild firma som tillhandahåller IT-konsulttjänster i Stockholm. Företaget är momsregistrerat (kvartalsvis), använder kontantmetoden och fakturerar både svenska och utländska kunder.',
|
||||
source_signals: { is_sandbox: true },
|
||||
field_overrides: {},
|
||||
composer_model: 'sandbox-demo',
|
||||
composer_version: 1,
|
||||
composed_at: new Date().toISOString(),
|
||||
verified_at: new Date().toISOString(),
|
||||
verified_by_user_id: null,
|
||||
intake_completed_at: new Date().toISOString(),
|
||||
})
|
||||
if (error) {
|
||||
log.warn('failed to backfill sandbox agent_profile', { error, companyId })
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('unexpected error backfilling sandbox agent_profile', { error: err, companyId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Sandbox guard — returns true if the given company is a sandbox company
|
||||
* (`company_settings.is_sandbox = true`). Used to short-circuit API routes
|
||||
* that would otherwise call paid external services (Anthropic Bedrock, the
|
||||
* Resend email API, Riksbanken FX, VIES, Skatteverket, Enable Banking, TIC).
|
||||
*
|
||||
* The sandbox is intentionally read-only against external systems: it must
|
||||
* never send a real email, charge a token, or speak to a tax authority on
|
||||
* behalf of an anonymous demo user. RLS and the `is_sandbox` flag on
|
||||
* company_settings are the single source of truth — we check it here on
|
||||
* every gated entry point so the demo can't accidentally outgrow its sandbox.
|
||||
*/
|
||||
export async function isSandboxCompany(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<boolean> {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return data?.is_sandbox === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard 403 response for sandbox-blocked endpoints. The bilingual envelope
|
||||
* matches the rest of the app's error shape — the UI picks the right field
|
||||
* via the active locale.
|
||||
*/
|
||||
export function sandboxBlockedResponse(): NextResponse {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Inte tillgängligt i sandlådan. Skapa ett konto för att använda AI-assistenten och externa tjänster.',
|
||||
error_en: 'Not available in the sandbox. Create an account to use the AI assistant and external services.',
|
||||
sandbox_blocked: true,
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: check + return the 403 in one call. Returns the
|
||||
* NextResponse to return from the route, or `null` when the company is not
|
||||
* a sandbox and the route should proceed.
|
||||
*
|
||||
* const blocked = await guardSandbox(supabase, companyId)
|
||||
* if (blocked) return blocked
|
||||
*/
|
||||
export async function guardSandbox(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<NextResponse | null> {
|
||||
if (await isSandboxCompany(supabase, companyId)) {
|
||||
return sandboxBlockedResponse()
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1524,7 +1524,7 @@
|
||||
"refresh_exhausted": "Refresh exhausted — connect again",
|
||||
"permissions_label": "Permissions",
|
||||
"missing_skattekonto": "The Skattekonto permission is missing — disconnect and reconnect to enable the balance and transactions view.",
|
||||
"missing_agd": "The employer declaration (AGI) permission is missing — disconnect and reconnect to send AGI directly from gnubok. Tokens issued before AGI support was activated lack this scope.",
|
||||
"missing_agd": "The employer declaration (AGI) permission is missing — disconnect and reconnect to send AGI directly from accounted. Tokens issued before AGI support was activated lack this scope.",
|
||||
"disabled_filings_message": "The Skatteverket integration is temporarily disabled. Filings are inactive.",
|
||||
"reconnect": "Reconnect",
|
||||
"disconnect": "Disconnect",
|
||||
@@ -3013,19 +3013,19 @@
|
||||
"ext_email_long_description": "Enables email features: send invoices to customers, automatic payment reminders (15/30/45 days), and email notifications. Requires a Resend account with a verified domain.",
|
||||
"ext_arcim_migration_name": "System migration",
|
||||
"ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox",
|
||||
"ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to gnubok. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.",
|
||||
"ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.",
|
||||
"ext_tic_name": "Company information",
|
||||
"ext_tic_description": "Fetch company information automatically at signup",
|
||||
"ext_tic_long_description": "Auto-fill company details by entering an organization number. Retrieves address, VAT registration, F-tax status and bank details from public registries via TIC.",
|
||||
"ext_mcp_server_name": "MCP server (API)",
|
||||
"ext_mcp_server_description": "Do bookkeeping via Claude, Cursor or another MCP client",
|
||||
"ext_mcp_server_long_description": "Exposes gnubok's bookkeeping engine as MCP tools (Model Context Protocol). Connect your MCP client with an API key and do bookkeeping through conversation: list uncategorized transactions, book them, create invoices.",
|
||||
"ext_mcp_server_long_description": "Exposes accounted's bookkeeping engine as MCP tools (Model Context Protocol). Connect your MCP client with an API key and do bookkeeping through conversation: list uncategorized transactions, book them, create invoices.",
|
||||
"ext_cloud_backup_name": "Cloud sync",
|
||||
"ext_cloud_backup_description": "Sync backups to your own cloud storage",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Gnubok creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive. Perfect for meeting your own redundancy requirements.",
|
||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive. Perfect for meeting your own redundancy requirements.",
|
||||
"ext_skatteverket_name": "Skatteverket integration",
|
||||
"ext_skatteverket_description": "Submit the VAT declaration directly to Skatteverket via BankID.",
|
||||
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from gnubok. Save drafts, validate, lock and sign — without leaving the app.",
|
||||
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from accounted. Save drafts, validate, lock and sign — without leaving the app.",
|
||||
"ext_invoice_inbox_name": "Document inbox",
|
||||
"ext_invoice_inbox_description": "Forward supplier invoices to a unique address — documents land here with extracted fields",
|
||||
"ext_invoice_inbox_long_description": "Each company gets a unique invoice inbox address. Invoices sent there are captured automatically and fields such as org. no., OCR, bankgiro, amount and due date are extracted deterministically from the PDF text. No AI calls, no cloud services beyond Resend for email delivery."
|
||||
|
||||
@@ -1524,7 +1524,7 @@
|
||||
"refresh_exhausted": "Förnyelse uttömd — anslut igen",
|
||||
"permissions_label": "Behörigheter",
|
||||
"missing_skattekonto": "Behörigheten för Skattekonto saknas — koppla från och anslut igen för att aktivera saldo- och transaktionsvyn.",
|
||||
"missing_agd": "Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla från och anslut igen för att kunna skicka AGI direkt från gnubok. Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.",
|
||||
"missing_agd": "Behörigheten för Arbetsgivardeklaration (AGI) saknas — koppla från och anslut igen för att kunna skicka AGI direkt från accounted. Tokens utfärdade innan AGI-stödet aktiverades saknar denna scope.",
|
||||
"disabled_filings_message": "Skatteverket-integrationen är tillfälligt avstängd. Inlämningar är inaktiverade.",
|
||||
"reconnect": "Anslut igen",
|
||||
"disconnect": "Koppla från",
|
||||
@@ -3013,19 +3013,19 @@
|
||||
"ext_email_long_description": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
|
||||
"ext_arcim_migration_name": "Systemmigration",
|
||||
"ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox",
|
||||
"ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.",
|
||||
"ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.",
|
||||
"ext_tic_name": "Bolagsuppgifter",
|
||||
"ext_tic_description": "Hämta företagsinformation automatiskt vid registrering",
|
||||
"ext_tic_long_description": "Fyll i företagsuppgifter automatiskt genom att ange organisationsnummer. Hämtar adress, momsregistrering, F-skattestatus och bankuppgifter från offentliga register via TIC.",
|
||||
"ext_mcp_server_name": "MCP-server (API)",
|
||||
"ext_mcp_server_description": "Gör bokföring via Claude, Cursor eller annan MCP-klient",
|
||||
"ext_mcp_server_long_description": "Exponerar gnuboks bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor.",
|
||||
"ext_mcp_server_long_description": "Exponerar accounteds bokföringsmotor som MCP-verktyg (Model Context Protocol). Koppla din MCP-klient med en API-nyckel och gör bokföring genom konversation: visa okategoriserade transaktioner, bokför dem, skapa fakturor.",
|
||||
"ext_cloud_backup_name": "Molnsynkronisering",
|
||||
"ext_cloud_backup_description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Gnubok skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive. Perfekt för att uppfylla egna krav på redundans.",
|
||||
"ext_skatteverket_name": "Skatteverket Integration",
|
||||
"ext_skatteverket_description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
||||
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från gnubok. Spara utkast, validera, lås och signera — utan att lämna appen.",
|
||||
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från accounted. Spara utkast, validera, lås och signera — utan att lämna appen.",
|
||||
"ext_invoice_inbox_name": "Dokumentinkorg",
|
||||
"ext_invoice_inbox_description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält",
|
||||
"ext_invoice_inbox_long_description": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning."
|
||||
|
||||
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 599 B After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 6.9 KiB |