fix(sandbox): lock what the sandbox cannot actually do (#1318)
* fix(sandbox): lock what the sandbox cannot actually do Three surfaces in the sandbox advertised capability the sandbox blocks outright, or rendered a staged preview wrong. Skatteverket promo card: hidden for sandbox companies. The sandbox landing page tells users Skatteverket is off, and the authorize route 403s via guardSandbox, so the dashboard nudge was a dead end. Same precedent as TaxSettingsContent, which already hides its Skatteverket section on is_sandbox. Dokumentinkorg: locked with a state that says what the workspace does and sends the user to registration. Checked before the capability gate on purpose: the seed_trial trigger grants every new company (sandbox included) 30 days of every paid capability, so the existing paywall waved a demo company straight through. The CTA signs the anonymous session out first, mirroring SandboxBanner. Staged categorize_transaction preview: the seed wrote its kontering under the generic preview_lines key, but categorize_transaction is the one type with a dedicated preview component, and it reads `lines`. The card fell through to its legacy summary branch and rendered blank Debetkonto and Kreditkonto plus "NaN kr" from formatCurrency(undefined). The seeded blob now mirrors what gnubok_categorize_transaction stages, extracted into buildSandboxPendingOperations so both shapes are unit-testable. CategorizePreview also learns to read preview_lines and to show a missing amount as a gap, so a live 24h sandbox stops showing NaN before its data expires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): don't leave for /register when sign-out failed CodeRabbit review: the ExtensionSandboxLockState CTA ignored the signOut() result, so a failure routed to /register with the anonymous session still live, which registers INTO the sandbox instead of leaving it: exactly what the sign-out exists to prevent. Surface the failure and stay put so the user can retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -717,3 +717,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-30] Report-vocabulary synonyms live in ReportDescriptor.searchTerms, NOT in the command-palette keywords, when they are words another report already owns. "stäm av"/"avstämning" on the huvudbok palette entry hijacked Enter from Bankavstämning, because the palette auto-selects the first hit and huvudbok is listed above it. The library search shows a list and has no such failure mode, so broad task-vocabulary belongs there.
|
||||
|
||||
[2026-07-30] A missing org number on either side of the Bokio connect probe does NOT block the connection; only a confident mismatch does. Accounted allows companies without an org number and a provider response can omit it, so blocking on absence would refuse legitimate connections to prevent a mismatch we have no evidence of. Absence instead falls through to labelling the consent with the company the credentials actually opened, which is what lets the user catch it. Same reasoning applied to keeping 429/5xx from the probe out of the invalid-credentials mapping: a provider outage must not read as "your token is wrong".
|
||||
|
||||
[2026-07-30] The sandbox lock on a paid extension workspace is checked BEFORE the capability gate, and gets its own state (ExtensionSandboxLockState), not the billing upsell. Every new company, sandbox included, gets a 30-day trial grant from the seed_trial trigger, so the paywall waves a demo company straight through onto a workspace whose external services lib/sandbox/guard.ts blocks. And an anonymous demo user has no billing to upgrade: the exit is "Skapa konto", which must sign the anonymous session out first (registering on top of it registers into the sandbox).
|
||||
|
||||
@@ -5,7 +5,13 @@ import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspace
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
|
||||
import { ExtensionUpsellState } from '@/components/extensions/ExtensionUpsellState'
|
||||
import { getDashboardAuthContext, getDashboardCompanyId } from '../../../request-context'
|
||||
import { ExtensionSandboxLockState } from '@/components/extensions/ExtensionSandboxLockState'
|
||||
import { extensionDescriptionKey, extensionNameKey } from '@/lib/extensions/i18n'
|
||||
import {
|
||||
getDashboardAuthContext,
|
||||
getDashboardCompanyId,
|
||||
getDashboardSettings,
|
||||
} from '../../../request-context'
|
||||
|
||||
export default async function ExtensionWorkspacePage({
|
||||
params,
|
||||
@@ -30,6 +36,30 @@ export default async function ExtensionWorkspacePage({
|
||||
// Fail closed: no resolvable company or the capability absent, both block.
|
||||
const requiredCapability = requiredCapabilityForExtension(sector, slug)
|
||||
if (requiredCapability) {
|
||||
// Sandbox first, and before the capability check: a demo company holds the
|
||||
// 30-day trial grant every new company gets, so the paywall waves it
|
||||
// through onto a workspace whose external services the sandbox blocks
|
||||
// (lib/sandbox/guard.ts). An anonymous user also has no billing to
|
||||
// upgrade, so the billing upsell below would be the wrong exit.
|
||||
const settings = await getDashboardSettings()
|
||||
if (settings.data?.is_sandbox === true) {
|
||||
const t = await getTranslations('extensions')
|
||||
// The manifest ships Swedish-only name/description; the slug maps to a
|
||||
// translated pair at the render layer (lib/extensions/i18n), same as the
|
||||
// sidebar. Fall back to the manifest for a slug with no mapping yet.
|
||||
const nameKey = extensionNameKey(slug)
|
||||
const descriptionKey = extensionDescriptionKey(slug)
|
||||
return (
|
||||
<ExtensionSandboxLockState
|
||||
iconName={definition.icon}
|
||||
title={t('sandbox_locked_title', { name: nameKey ? t(nameKey) : definition.name })}
|
||||
description={descriptionKey ? t(descriptionKey) : definition.description}
|
||||
note={t('sandbox_locked_note')}
|
||||
ctaLabel={t('sandbox_locked_cta')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const allowed = companyId
|
||||
? await hasCapability(supabase, companyId, requiredCapability)
|
||||
: false
|
||||
|
||||
@@ -330,8 +330,24 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Some operations carry their kontering under the generic `preview_lines`
|
||||
// key instead (the shape every other staged type renders through). Read it
|
||||
// before falling through to the legacy summary, which would otherwise show
|
||||
// blank accounts for a preview that does describe the entry in full.
|
||||
if (isKonteringLines(data.preview_lines)) {
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
|
||||
<PreviewKonteringTable lines={data.preview_lines} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy summary for operations staged before the preview carried full
|
||||
// lines: debit/credit accounts + gross amount + separate VAT rows.
|
||||
const legacyAmount = typeof data.amount === 'number' && Number.isFinite(data.amount)
|
||||
? data.amount
|
||||
: null
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
@@ -341,7 +357,11 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
|
||||
<span className="font-mono">{String(data.credit_account ?? '')}</span>
|
||||
<span className="text-muted-foreground">Belopp</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatCurrency(data.amount as number, (data.currency as string) || 'SEK')}
|
||||
{/* A preview with no usable amount used to render "NaN kr": show the
|
||||
gap as a gap instead of a number that isn't one. */}
|
||||
{legacyAmount === null
|
||||
? '-'
|
||||
: formatCurrency(legacyAmount, (data.currency as string) || 'SEK')}
|
||||
</span>
|
||||
</div>
|
||||
{vatLines.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { buildSandboxPendingOperations } from '../pending-operations'
|
||||
|
||||
const input = {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
inboxItemId: 'inbox-1',
|
||||
supplierId: 'supplier-1',
|
||||
invoiceDate: '2026-07-25',
|
||||
dueDate: '2026-08-06',
|
||||
transactionId: 'tx-1',
|
||||
}
|
||||
|
||||
/** Sum of a kontering in the `lines` (account_number/debit_amount) spelling. */
|
||||
function sums(lines: Array<{ debit_amount: number; credit_amount: number }>) {
|
||||
return {
|
||||
debit: lines.reduce((n, l) => n + l.debit_amount, 0),
|
||||
credit: lines.reduce((n, l) => n + l.credit_amount, 0),
|
||||
}
|
||||
}
|
||||
|
||||
describe('sandbox pending-operation seed data', () => {
|
||||
it('stages both demo operations under the RLS-required actor shape', () => {
|
||||
const ops = buildSandboxPendingOperations(input)
|
||||
|
||||
expect(ops).toHaveLength(2)
|
||||
// pending_operations_chat_insert is the only policy that lets a
|
||||
// user-scoped client INSERT here, and it requires both fields.
|
||||
expect(ops.every((op) => op.actor_type === 'agent_chat')).toBe(true)
|
||||
expect(ops.every((op) => op.risk_level === 'low')).toBe(true)
|
||||
expect(ops.every((op) => op.status === 'pending')).toBe(true)
|
||||
expect(ops.every((op) => op.user_id === 'user-1' && op.company_id === 'company-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('threads the seeded row ids into the executor params', () => {
|
||||
const [supplierInvoice, categorize] = buildSandboxPendingOperations(input)
|
||||
|
||||
expect(supplierInvoice.params).toMatchObject({
|
||||
inbox_item_id: 'inbox-1',
|
||||
supplier_id: 'supplier-1',
|
||||
invoice_date: '2026-07-25',
|
||||
due_date: '2026-08-06',
|
||||
})
|
||||
expect(categorize.params).toMatchObject({ transaction_id: 'tx-1' })
|
||||
})
|
||||
|
||||
it('gives categorize_transaction the preview shape CategorizePreview reads', () => {
|
||||
const categorize = buildSandboxPendingOperations(input).find(
|
||||
(op) => op.operation_type === 'categorize_transaction',
|
||||
)!
|
||||
const preview = categorize.preview_data as {
|
||||
amount?: unknown
|
||||
debit_account?: unknown
|
||||
credit_account?: unknown
|
||||
lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
|
||||
// The regression this guards: seeding only the generic `preview_lines` key
|
||||
// dropped the card onto the legacy summary branch, which rendered blank
|
||||
// accounts and "NaN kr" from formatCurrency(undefined).
|
||||
expect(preview.lines).toBeDefined()
|
||||
expect(preview.lines!.length).toBeGreaterThan(0)
|
||||
expect(typeof preview.amount).toBe('number')
|
||||
expect(Number.isFinite(preview.amount as number)).toBe(true)
|
||||
expect(preview.debit_account).toBe('1930')
|
||||
expect(preview.credit_account).toBe('3001')
|
||||
})
|
||||
|
||||
it('previews a balanced verifikat for the 1 200 kr deposit', () => {
|
||||
const categorize = buildSandboxPendingOperations(input).find(
|
||||
(op) => op.operation_type === 'categorize_transaction',
|
||||
)!
|
||||
const preview = categorize.preview_data as {
|
||||
amount: number
|
||||
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
}
|
||||
|
||||
const { debit, credit } = sums(preview.lines)
|
||||
expect(debit).toBe(credit)
|
||||
// Gross on the bank line matches the summary amount the card headlines.
|
||||
expect(debit).toBe(preview.amount)
|
||||
expect(preview.lines.map((l) => l.account_number)).toEqual(['1930', '2611', '3001'])
|
||||
})
|
||||
|
||||
it('keeps the supplier-invoice preview on the generic preview_lines shape', () => {
|
||||
const supplierInvoice = buildSandboxPendingOperations(input).find(
|
||||
(op) => op.operation_type === 'create_supplier_invoice_from_inbox',
|
||||
)!
|
||||
const preview = supplierInvoice.preview_data as {
|
||||
preview_lines: Array<{ account: string; debit: number; credit: number }>
|
||||
}
|
||||
|
||||
// No dedicated preview component for this type: GenericPreview renders a
|
||||
// kontering under `preview_lines` in the account/debit/credit spelling.
|
||||
expect(preview.preview_lines).toHaveLength(3)
|
||||
const debit = preview.preview_lines.reduce((n, l) => n + l.debit, 0)
|
||||
const credit = preview.preview_lines.reduce((n, l) => n + l.credit, 0)
|
||||
expect(roundOre(debit)).toBe(roundOre(credit))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Pre-staged pending_operations for the sandbox, so /pending isn't empty.
|
||||
*
|
||||
* These are the kind of operation the AI agent would stage; pre-seeded so the
|
||||
* demo user can see the approval queue UI (preview, period status, risk level)
|
||||
* without invoking the AI, which the sandbox blocks outright.
|
||||
*
|
||||
* Two shapes have to be right or the row is worse than absent:
|
||||
* params: executor-complete. The commit executors in
|
||||
* lib/pending-operations/commit.ts validate required fields on "Godkänn",
|
||||
* so a display-only preview with a hollow params object fails to save.
|
||||
* preview_data: whatever the operation's preview component in
|
||||
* app/(dashboard)/pending/page.tsx actually reads. Most types fall through
|
||||
* to GenericPreview, which renders a kontering under `preview_lines`;
|
||||
* categorize_transaction has a dedicated CategorizePreview that reads
|
||||
* `lines` + a summary `amount` instead.
|
||||
*
|
||||
* Extracted from route.ts so both shapes are assertable in a unit test: the
|
||||
* seed handler itself is one long Supabase-bound function.
|
||||
*/
|
||||
|
||||
export interface SandboxPendingOperationsInput {
|
||||
userId: string
|
||||
companyId: string
|
||||
/** invoice_inbox_items row the supplier-invoice operation converts. */
|
||||
inboxItemId: string
|
||||
supplierId: string
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
/** The uncategorized 1 200 kr bankgiro deposit. */
|
||||
transactionId: string
|
||||
}
|
||||
|
||||
export function buildSandboxPendingOperations({
|
||||
userId,
|
||||
companyId,
|
||||
inboxItemId,
|
||||
supplierId,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
transactionId,
|
||||
}: SandboxPendingOperationsInput) {
|
||||
return [
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
operation_type: 'create_supplier_invoice_from_inbox',
|
||||
status: 'pending',
|
||||
// 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).
|
||||
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 by the seed (BFL 5 kap: each
|
||||
// affärshändelse must be recorded exactly once).
|
||||
title: 'Registrera leverantörsfaktura, Demokafé (representation, nytt underlag)',
|
||||
// Mirrors what gnubok_create_supplier_invoice_from_inbox would stage:
|
||||
// every field commitCreateSupplierInvoiceFromInbox requires
|
||||
// (inbox_item_id, supplier_id, supplier_invoice_number, invoice_date,
|
||||
// finite subtotal/vat_amount/total, and a non-empty items array).
|
||||
params: {
|
||||
inbox_item_id: inboxItemId,
|
||||
supplier_id: supplierId,
|
||||
document_id: null,
|
||||
supplier_invoice_number: 'INKOMMANDE-2026-001',
|
||||
invoice_date: invoiceDate,
|
||||
due_date: dueDate,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
vat_treatment: 'reduced_12',
|
||||
subtotal: 240,
|
||||
vat_amount: 28.80,
|
||||
total: 268.80,
|
||||
notes: 'Representation, kundmöte (demo)',
|
||||
items: [
|
||||
{
|
||||
line_number: 1,
|
||||
description: 'Kundmöte Demokafé (representation)',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 240,
|
||||
line_total: 240,
|
||||
account_number: '5810',
|
||||
vat_rate: 12,
|
||||
vat_amount: 28.80,
|
||||
},
|
||||
],
|
||||
},
|
||||
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.
|
||||
// GenericPreview renders this key, so the `account`/`debit`/`credit`
|
||||
// spelling is the right one here.
|
||||
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',
|
||||
// commitCategorizeTransaction needs a real uncategorized transaction_id
|
||||
// + a category that resolves to an account mapping. income_services →
|
||||
// 3001 (Försäljning tjänster 25%), matching the 1930 / 2611 / 3001 split
|
||||
// below for the 1 200 kr deposit.
|
||||
params: {
|
||||
transaction_id: transactionId,
|
||||
category: 'income_services',
|
||||
vat_treatment: 'standard_25',
|
||||
},
|
||||
// CategorizePreview reads `lines`, NOT the generic `preview_lines` the
|
||||
// operation above uses. Seeding the generic shape here dropped the card
|
||||
// onto its legacy summary branch: blank Debetkonto/Kreditkonto and
|
||||
// "NaN kr" from formatCurrency(undefined) on the missing `amount`.
|
||||
// Mirror exactly what gnubok_categorize_transaction stages.
|
||||
preview_data: {
|
||||
debit_account: '1930',
|
||||
credit_account: '3001',
|
||||
amount: 1200,
|
||||
currency: 'SEK',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1200, credit_amount: 0, description: 'Företagskonto' },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 240, description: 'Utgående moms 25%' },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 960, description: 'Försäljning 25% moms' },
|
||||
],
|
||||
vat_lines: [
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 240, description: 'Utgående moms 25%' },
|
||||
],
|
||||
category: 'income_services',
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { truncateIp } from '@/lib/api/v1/with-api-v1'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import { buildSandboxCustomers } from './customers'
|
||||
import { buildSandboxPendingOperations } from './pending-operations'
|
||||
|
||||
// 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
|
||||
@@ -841,102 +842,22 @@ export async function POST(request: Request) {
|
||||
|
||||
if (inboxError) throw inboxError
|
||||
|
||||
// 17. 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. Each
|
||||
// params blob must be executor-complete: the commit executors in
|
||||
// lib/pending-operations/commit.ts validate required fields on "Godkänn",
|
||||
// so a display-only preview with a hollow params object fails to save.
|
||||
// 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).
|
||||
// 17. Pre-staged pending_operations so /pending isn't empty. Both the
|
||||
// executor-complete params and the per-type preview_data shapes live in
|
||||
// ./pending-operations, where they are unit-testable.
|
||||
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)',
|
||||
// Mirrors what gnubok_create_supplier_invoice_from_inbox would stage:
|
||||
// every field commitCreateSupplierInvoiceFromInbox requires
|
||||
// (inbox_item_id, supplier_id, supplier_invoice_number, invoice_date,
|
||||
// finite subtotal/vat_amount/total, and a non-empty items array).
|
||||
params: {
|
||||
inbox_item_id: inboxRow.id,
|
||||
supplier_id: supplierMap['Demokafé AB'],
|
||||
document_id: null,
|
||||
supplier_invoice_number: 'INKOMMANDE-2026-001',
|
||||
invoice_date: toDateStr(fiveDaysAgo),
|
||||
due_date: toDateStr(sevenDaysFromNow),
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
vat_treatment: 'reduced_12',
|
||||
subtotal: 240,
|
||||
vat_amount: 28.80,
|
||||
total: 268.80,
|
||||
notes: 'Representation, kundmöte (demo)',
|
||||
items: [
|
||||
{
|
||||
line_number: 1,
|
||||
description: 'Kundmöte Demokafé (representation)',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 240,
|
||||
line_total: 240,
|
||||
account_number: '5810',
|
||||
vat_rate: 12,
|
||||
vat_amount: 28.80,
|
||||
},
|
||||
],
|
||||
},
|
||||
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',
|
||||
// commitCategorizeTransaction needs a real uncategorized
|
||||
// transaction_id + a category that resolves to an account mapping.
|
||||
// income_services → 3001 (Försäljning tjänster 25%), matching the
|
||||
// preview's 1930 / 2611 / 3001 split for the 1 200 kr deposit.
|
||||
params: {
|
||||
transaction_id: txMap['INSÄTTNING BANKGIRO'],
|
||||
category: 'income_services',
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
.insert(
|
||||
buildSandboxPendingOperations({
|
||||
userId,
|
||||
companyId,
|
||||
inboxItemId: inboxRow.id,
|
||||
supplierId: supplierMap['Demokafé AB'],
|
||||
invoiceDate: toDateStr(fiveDaysAgo),
|
||||
dueDate: toDateStr(sevenDaysFromNow),
|
||||
transactionId: txMap['INSÄTTNING BANKGIRO'],
|
||||
}),
|
||||
)
|
||||
|
||||
if (pendOpsError) throw pendOpsError
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ArrowRight, FileCheck, X } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
const dismissKey = (companyId: string) => `erp_skv_promo_dismissed:${companyId}`
|
||||
@@ -36,11 +36,14 @@ interface SkatteverketPromoCardProps {
|
||||
* otherwise only discover the integration deep inside the VAT/AGI flows.
|
||||
* Capability-less users never see it: the paywall upsell lives where the
|
||||
* intent is (SkatteverketPanel, AGIPanel), not on the dashboard.
|
||||
* Sandbox companies never see it either: Skatteverket is one of the external
|
||||
* services the sandbox blocks outright, so the CTA would lead nowhere.
|
||||
*/
|
||||
export function SkatteverketPromoCard({ companyId, connected }: SkatteverketPromoCardProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const extensionEnabled = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
const hasCapability = useCapability(CAPABILITY.skatteverket)
|
||||
const isSandbox = useCompanyOptional()?.isSandbox ?? false
|
||||
|
||||
// Server snapshot says dismissed: the card appears only after hydration,
|
||||
// when localStorage is readable, so server and client never disagree.
|
||||
@@ -55,7 +58,7 @@ export function SkatteverketPromoCard({ companyId, connected }: SkatteverketProm
|
||||
window.dispatchEvent(new Event(DISMISS_EVENT))
|
||||
}, [companyId])
|
||||
|
||||
if (!extensionEnabled || !hasCapability || connected || dismissed) return null
|
||||
if (!extensionEnabled || !hasCapability || isSandbox || connected || dismissed) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
|
||||
interface ExtensionSandboxLockStateProps {
|
||||
iconName?: string
|
||||
title: string
|
||||
/** One line on what the workspace actually does, so a locked page still explains itself. */
|
||||
description: string
|
||||
/** Why it is locked here and what unlocks it. */
|
||||
note: string
|
||||
ctaLabel: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox lock for an extension workspace whose value is an external service
|
||||
* (invoice-inbox: AI field extraction plus the forwarding mail address). The
|
||||
* sandbox blocks those services outright (lib/sandbox/guard.ts), so an
|
||||
* unlocked workspace looks functional and then quietly does nothing.
|
||||
*
|
||||
* Deliberately not ExtensionUpsellState: an anonymous demo user has no billing
|
||||
* to upgrade, they need an account. The CTA signs the anonymous session out
|
||||
* first, mirroring SandboxBanner: /register on top of a live anonymous session
|
||||
* registers into the sandbox instead of leaving it.
|
||||
*
|
||||
* Same RSC constraint as ExtensionUpsellState: every prop stays a plain string
|
||||
* and the icon is resolved client-side from its name, because passing a
|
||||
* resolved component across the server/client boundary 500s the page.
|
||||
*/
|
||||
export function ExtensionSandboxLockState({
|
||||
iconName,
|
||||
title,
|
||||
description,
|
||||
note,
|
||||
ctaLabel,
|
||||
}: ExtensionSandboxLockStateProps) {
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
const router = useRouter()
|
||||
const t = useTranslations('extensions')
|
||||
const { toast } = useToast()
|
||||
|
||||
async function handleCreateAccount() {
|
||||
setIsLeaving(true)
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) {
|
||||
// Navigating anyway would land on /register with the anonymous session
|
||||
// still live, which registers INTO the sandbox: the exact outcome the
|
||||
// sign-out exists to prevent. Stay put and let the user retry.
|
||||
toast({
|
||||
title: t('sandbox_locked_signout_error_title'),
|
||||
description: t('sandbox_locked_signout_error_description'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsLeaving(false)
|
||||
return
|
||||
}
|
||||
router.push('/register')
|
||||
}
|
||||
|
||||
const Icon = iconName ? resolveIcon(iconName) : undefined
|
||||
|
||||
return (
|
||||
<EmptyState icon={Icon} title={title} description={description}>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="max-w-sm text-sm text-muted-foreground text-balance">{note}</p>
|
||||
<Button onClick={handleCreateAccount} disabled={isLeaving}>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
@@ -4511,6 +4511,11 @@
|
||||
"upsell_title": "Upgrade to use {name}",
|
||||
"upsell_description": "This feature is included in a paid subscription. Upgrade to enable it.",
|
||||
"upsell_cta": "Upgrade",
|
||||
"sandbox_locked_title": "{name} is locked in the sandbox",
|
||||
"sandbox_locked_note": "The sandbox runs entirely without external services, so this feature is switched off here. Create an account to use it for real.",
|
||||
"sandbox_locked_cta": "Create account",
|
||||
"sandbox_locked_signout_error_title": "Could not leave the sandbox",
|
||||
"sandbox_locked_signout_error_description": "Please try again in a moment.",
|
||||
"breadcrumb": "Extensions",
|
||||
"extension_count": "{count} extensions",
|
||||
"data_pattern_core": "Uses bookkeeping data",
|
||||
|
||||
@@ -4511,6 +4511,11 @@
|
||||
"upsell_title": "Uppgradera för att använda {name}",
|
||||
"upsell_description": "Den här funktionen ingår i en betald prenumeration. Uppgradera för att aktivera den.",
|
||||
"upsell_cta": "Uppgradera",
|
||||
"sandbox_locked_title": "{name} är låst i sandlådan",
|
||||
"sandbox_locked_note": "Sandlådan kör helt utan externa tjänster, så den här funktionen är avstängd här. Skapa ett konto för att använda den på riktigt.",
|
||||
"sandbox_locked_cta": "Skapa konto",
|
||||
"sandbox_locked_signout_error_title": "Kunde inte lämna sandlådan",
|
||||
"sandbox_locked_signout_error_description": "Försök igen om en stund.",
|
||||
"breadcrumb": "Tillägg",
|
||||
"extension_count": "{count} tillägg",
|
||||
"data_pattern_core": "Använder bokföringsdata",
|
||||
|
||||
Reference in New Issue
Block a user