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:
co-authored by
Claude Opus 5
parent
0f1c7c9365
commit
f49dc3438d
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user