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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-18 10:46:34 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 387e1fb7f1
commit cfdddb2d7e
10 changed files with 216 additions and 2 deletions
@@ -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()
+8
View File
@@ -347,6 +347,13 @@ async function commitCreateCustomer(
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
// 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,