diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx
index b5cd0977..c5a0a36e 100644
--- a/app/(dashboard)/import/page.tsx
+++ b/app/(dashboard)/import/page.tsx
@@ -2464,6 +2464,7 @@ export default function ImportPage() {
}
chips={}
disabled={woocommerceDisabled}
onClick={() => setMode('woocommerce')}
@@ -2473,6 +2474,7 @@ export default function ImportPage() {
}
chips={}
disabled={shopifyDisabled}
onClick={() => setMode('shopify')}
@@ -2776,6 +2778,16 @@ function ImportRow({
)
}
+// Same quiet beta-badge recipe as the sidebar nav (DashboardNav renderBadge),
+// so "Beta" reads identically wherever it appears.
+function BetaChip({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
// Provider mark chip (same recipe as the pre-migration live page): tiny logo
// on a quiet bordered chip, so integrations read as first-class brands.
// `mono` is for light-on-transparent marks (Enable Banking): the marketing
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index 804bdbee..0fd8ccf2 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -214,7 +214,7 @@ const navItems: NavItem[] = [
// hooked up (active WooCommerce/Shopify connection or existing order rows).
// Deliberately NOT capability-gated: a company whose entitlement lapsed
// must still reach its already-imported orders (accounting underlag).
- { href: '/orders', labelKey: 'sales_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true },
+ { href: '/orders', labelKey: 'sales_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true },
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
// Körjournal: hidden by default (most companies have no car); shows when
diff --git a/components/pending-operations/OperationPreview.tsx b/components/pending-operations/OperationPreview.tsx
index a4e1625e..2cdfd16c 100644
--- a/components/pending-operations/OperationPreview.tsx
+++ b/components/pending-operations/OperationPreview.tsx
@@ -156,6 +156,12 @@ function CustomerPreview({ data }: { data: Record }) {
{String(data.name ?? '')}
Typ
{String(data.customer_type ?? '')}
+ {data.customer_number ? (
+ <>
+ Kundnr
+ {String(data.customer_number)}
+ >
+ ) : null}
{data.email ? (
<>
E-post
diff --git a/extensions/general/mcp-server/__tests__/create-customer.test.ts b/extensions/general/mcp-server/__tests__/create-customer.test.ts
new file mode 100644
index 00000000..ba2ceb5c
--- /dev/null
+++ b/extensions/general/mcp-server/__tests__/create-customer.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { eventBus } from '@/lib/events/bus'
+import { tools } from '../server'
+
+const tool = () => tools.find((candidate) => candidate.name === 'gnubok_create_customer')!
+
+describe('gnubok_create_customer: customer_number input', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ })
+
+ it('exposes customer_number in the strict input schema', () => {
+ const properties = tool().inputSchema.properties as Record>
+ expect(tool().inputSchema.additionalProperties).toBe(false)
+ expect(properties.customer_number).toMatchObject({ type: 'string', maxLength: 32 })
+ })
+
+ it('stages the trimmed customer_number in params and preview', async () => {
+ const { supabase, enqueue, findCall } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-create-customer-1' } })
+
+ const result = (await tool().execute(
+ {
+ name: 'Kund AB',
+ customer_type: 'swedish_business',
+ customer_number: ' K-1001 ',
+ email: 'faktura@example.test',
+ },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as { staged: boolean; operation_id?: string; preview: Record }
+
+ expect(result.staged).toBe(true)
+ expect(result.operation_id).toBe('op-create-customer-1')
+ expect(result.preview).toMatchObject({
+ customer_number: 'K-1001',
+ email: 'faktura@example.test',
+ })
+
+ const inserted = findCall('pending_operations', 'insert')?.[0] as {
+ params: Record
+ }
+ expect(inserted.params).toMatchObject({ customer_number: 'K-1001' })
+ })
+
+ it('rejects a customer_number longer than 32 characters before staging', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ tool().execute(
+ { name: 'Kund AB', customer_type: 'swedish_business', customer_number: 'X'.repeat(33) },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/32/)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('rejects a non-string customer_number before staging', async () => {
+ const { supabase } = createQueuedMockSupabase()
+
+ await expect(
+ tool().execute(
+ { name: 'Kund AB', customer_type: 'swedish_business', customer_number: 1001 },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ ),
+ ).rejects.toThrow(/string/i)
+ expect(supabase.from).not.toHaveBeenCalled()
+ })
+
+ it('stages customer_number as null when omitted', async () => {
+ const { supabase, enqueue, findCall } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-create-customer-2' } })
+
+ const result = (await tool().execute(
+ { name: 'Kund AB', customer_type: 'swedish_business' },
+ 'company-1',
+ 'user-1',
+ supabase as never,
+ )) as { staged: boolean; preview: Record }
+
+ expect(result.staged).toBe(true)
+ expect(result.preview).toMatchObject({ customer_number: null })
+
+ const inserted = findCall('pending_operations', 'insert')?.[0] as {
+ params: Record
+ }
+ expect(inserted.params).toMatchObject({ customer_number: null })
+ })
+})
diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
index 81efe426..fd9789cc 100644
--- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
+++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts
@@ -171,9 +171,14 @@ describe('tools/list payload size guard', () => {
// * 59.5K to 59.7K with account VAT treatments: create_account and
// update_account both expose the 12-value treatment vocabulary. The
// descriptions are minimal; the enum values are the wire contract.
+ // * 59.7K to 59.75K with customer_number on gnubok_create_customer:
+ // parity with gnubok_update_customer, so setting a customer number no
+ // longer needs a second staged update after create. The property has
+ // no description (name + maxLength are the whole contract); headroom
+ // before the change was ~11 tokens, so even that minimal form crossed.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
- expect(approxTokens).toBeLessThan(59_700)
+ expect(approxTokens).toBeLessThan(59_750)
})
})
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index 792e37ab..a64b9095 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -4565,6 +4565,7 @@ export const tools: McpTool[] = [
enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'],
description: 'Customer type',
},
+ customer_number: { type: 'string', maxLength: 32 },
email: { type: 'string', description: 'Email address' },
org_number: { type: 'string', description: 'Swedish org number' },
vat_number: { type: 'string', description: 'EU VAT number' },
@@ -4599,9 +4600,20 @@ export const tools: McpTool[] = [
throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business')
}
+ // Runtime guard (hosts don't always enforce inputSchema maxLength).
+ const customerNumberArg = args.customer_number
+ if (customerNumberArg != null && typeof customerNumberArg !== 'string') {
+ throw new Error('customer_number must be a string.')
+ }
+ const customerNumber = typeof customerNumberArg === 'string' ? customerNumberArg.trim() : ''
+ if (customerNumber.length > 32) {
+ throw new Error('customer_number must be at most 32 characters.')
+ }
+
const params = {
name: name.trim(),
customer_type: customerType,
+ customer_number: customerNumber || null,
email: (args.email as string) || null,
org_number: (args.org_number as string) || null,
vat_number: (args.vat_number as string) || null,
diff --git a/lib/pending-operations/__tests__/executors.test.ts b/lib/pending-operations/__tests__/executors.test.ts
index c19deb0e..0deb77e8 100644
--- a/lib/pending-operations/__tests__/executors.test.ts
+++ b/lib/pending-operations/__tests__/executors.test.ts
@@ -204,6 +204,79 @@ describe('commitPendingOperation: unlock_period', () => {
})
})
+// ─── create_customer ────────────────────────────────────────────────
+
+describe('commitPendingOperation: create_customer', () => {
+ it('inserts the staged customer_number', async () => {
+ const { supabase, enqueue, findCall } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: makeCustomer({ id: 'cust-1', customer_number: 'K-1001' }),
+ error: null,
+ }) // customers insert
+ enqueue({ data: null, error: null }) // dispatcher's pending_operations update
+
+ const op = makePendingOp({
+ operation_type: 'create_customer',
+ params: {
+ name: 'Kund AB',
+ customer_type: 'swedish_business',
+ customer_number: 'K-1001',
+ email: 'faktura@example.test',
+ },
+ })
+
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('committed')
+ expect(findCall('customers', 'insert')?.[0]).toMatchObject({
+ company_id: 'company-1',
+ name: 'Kund AB',
+ customer_number: 'K-1001',
+ email: 'faktura@example.test',
+ })
+ })
+
+ it('rejects a staged customer_number longer than 32 characters without inserting', async () => {
+ const { supabase, enqueue, findCall } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({ data: null, error: null }) // dispatcher's reject update
+
+ const op = makePendingOp({
+ operation_type: 'create_customer',
+ params: {
+ name: 'Kund AB',
+ customer_type: 'swedish_business',
+ customer_number: 'X'.repeat(33),
+ },
+ })
+
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('failed')
+ expect(result.http_status).toBe(400)
+ expect(result.error).toMatch(/32/)
+ expect(findCall('customers', 'insert')).toBeUndefined()
+ })
+
+ it('inserts customer_number as null when not staged', async () => {
+ const { supabase, enqueue, findCall } = createQueuedMockSupabase()
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({ data: makeCustomer({ id: 'cust-1' }), error: null }) // customers insert
+ enqueue({ data: null, error: null }) // dispatcher's pending_operations update
+
+ const op = makePendingOp({
+ operation_type: 'create_customer',
+ params: { name: 'Kund AB', customer_type: 'swedish_business' },
+ })
+
+ const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
+
+ expect(result.status).toBe('committed')
+ expect(findCall('customers', 'insert')?.[0]).toMatchObject({ customer_number: null })
+ })
+})
+
describe('commitPendingOperation: credit-note issuance guard', () => {
it('rejects mark_invoice_sent before the ordinary invoice executor can book it', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index ce57fdaf..e48903fb 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -347,6 +347,13 @@ async function commitCreateCustomer(
companyId: string,
params: Record
): Promise {
+ // Same 32-char invariant the web/v1 create routes and commitUpdateCustomer
+ // enforce; MCP hosts don't reliably enforce inputSchema maxLength.
+ const customerNumber = params.customer_number
+ if (customerNumber != null && (typeof customerNumber !== 'string' || customerNumber.length > 32)) {
+ return { error: 'customer_number must be a string of at most 32 characters', status: 400 }
+ }
+
const { data, error } = await supabase
.from('customers')
.insert({
@@ -354,6 +361,7 @@ async function commitCreateCustomer(
company_id: companyId,
name: params.name as string,
customer_type: params.customer_type as string,
+ customer_number: customerNumber || null,
email: (params.email as string) || null,
org_number: (params.org_number as string) || null,
vat_number: (params.vat_number as string) || null,
diff --git a/messages/en.json b/messages/en.json
index 96600b92..b8d0a586 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -7083,6 +7083,7 @@
"stripe_description": "Connect your company's Stripe account to fetch payments, fees, and payouts continuously and book them against the Stripe balance.",
"stripe_not_enabled_title": "The Stripe extension is not enabled",
"stripe_not_enabled_description": "Enable the Stripe extension to connect your Stripe account and sync transactions automatically.",
+ "badge_beta": "Beta",
"woocommerce_title": "WooCommerce",
"woocommerce_description": "Connect your WooCommerce store to fetch paid orders and refunds into the transaction inbox.",
"woocommerce_not_enabled_title": "The WooCommerce extension is not enabled",
diff --git a/messages/sv.json b/messages/sv.json
index bb924432..49ced5bd 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -7083,6 +7083,7 @@
"stripe_description": "Koppla företagets Stripe-konto så hämtas betalningar, avgifter och utbetalningar löpande och bokförs mot Stripe-saldot.",
"stripe_not_enabled_title": "Stripe-tillägget är inte aktiverat",
"stripe_not_enabled_description": "Aktivera tillägget Stripe för att koppla ditt Stripe-konto och synka transaktioner automatiskt.",
+ "badge_beta": "Beta",
"woocommerce_title": "WooCommerce",
"woocommerce_description": "Koppla din WooCommerce-butik så hämtas betalda ordrar och återbetalningar till transaktionsinkorgen.",
"woocommerce_not_enabled_title": "WooCommerce-tillägget är inte aktiverat",