From cfdddb2d7e55b18fce70204a814f30f1d71d0378 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:46:34 +0200 Subject: [PATCH] feat(mcp): customer_number on create_customer + Beta tags on webshop surfaces (#1677) * feat(mcp): accept customer_number on gnubok_create_customer Parity with gnubok_update_customer: a customer number no longer needs a create-then-update two-step with two approvals. The staged params carry the trimmed number, commitCreateCustomer inserts it, and the payload-size ceiling is bumped 59.7K to 59.75K with a documented entry (the property has no description; name + maxLength are the whole contract). Requested by a user on Discord 2026-08-16. Co-Authored-By: Claude Fable 5 * feat(ui): mark webshop integrations and orders tab as Beta WooCommerce and Shopify rows on the import page get a quiet Beta chip next to the title, and the webshop /orders sidebar item sets the existing betaBadge flag. Chip recipe matches the nav beta badge so Beta reads identically everywhere. Co-Authored-By: Claude Fable 5 * fix(mcp): enforce customer_number invariants and show it on the approval card Consolidated resolution pass for PR #1677: - skeptic (correctness): maxLength 32 was advertisement-only on the create path; now enforced with a runtime guard in gnubok_create_customer execute (clean errors for non-string and >32) and a 400 guard in commitCreateCustomer, matching the web/v1 routes and commitUpdateCustomer. - skeptic (correctness): CustomerPreview never rendered the staged customer_number, leaving the approver blind to the new field; added a conditional Kundnr row. - CodeRabbit: reset the event bus in create-customer.test.ts beforeEach. - Tests cover both new guards at the tool and executor layers. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/import/page.tsx | 12 +++ components/dashboard/DashboardNav.tsx | 2 +- .../pending-operations/OperationPreview.tsx | 6 ++ .../__tests__/create-customer.test.ts | 96 +++++++++++++++++++ .../__tests__/payload-size.bench.test.ts | 7 +- extensions/general/mcp-server/server.ts | 12 +++ .../__tests__/executors.test.ts | 73 ++++++++++++++ lib/pending-operations/commit.ts | 8 ++ messages/en.json | 1 + messages/sv.json | 1 + 10 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 extensions/general/mcp-server/__tests__/create-customer.test.ts 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",