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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f53725b20a
commit
20989379bb
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user