fix(suppliers): stop requiring standardkonto that was never meant to be required (#1636)
* fix(suppliers): stop requiring standardkonto that was never meant to be required The supplier form initializes every optional field to '' and sent them as-is, while CreateSupplierSchema validates default_expense_account with the 4-digit account rule behind .optional(): an empty string is a present string, so saving a supplier with the field untouched failed with "Kontonummer måste vara 4 siffror" even though the field carries no required mark (reported by Björn with a screen recording; the edit page failed the same way for any supplier without a default account). Schemas now own the normalization, split by verb: on create '' becomes undefined (key dropped, column NULL), on update '' becomes null, because update routes pass fields straight into .update() where undefined means "leave unchanged" and clearing must actually write NULL. Email gets the same treatment and the form's old client-side email strip is removed; stripping empty strings client-side would break exactly the clear path. The free-text Standardkonto input is replaced with the shared AccountCombobox (browsable list filtered to cost classes 4-7, the same rule the agent-path expenseAccountField enforces), with the selected account name shown under the field and a clear button when set. Standardkonto itself stays optional: it only prefills supplier-invoice lines and the ledger-context suggestion covers the empty case. Verified end to end against the running app: saving a supplier without a default account succeeds on the update path, and the combobox search/select/clear cycle works inside the create dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance The minimal Zod-to-JSON-schema walker described every pipe by its input side. For .transform() that is right (the caller sends the input), but z.preprocess() is the mirror image: the callable sits on the input side, so the supplier schemas' new empty-string normalization rendered email and default_expense_account as required untyped fields in the OpenAPI spec and the generated accounted-api skill. Describe the output side when the input is a transform. Required-ness now derives from schema.safeParse(undefined) instead of a top-level discriminator check: a field may be omitted exactly when the schema accepts undefined. Besides the preprocess pipes, this corrects several fields the old check misrendered as required (z.unknown() bodies, union-with-empty-string settings fields, preprocessed personal_number), so the regenerated skill references only flip required to optional where runtime validation already allowed omission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
2eb3441244
commit
25524e1df4
@@ -743,6 +743,24 @@ describe('CreateSupplierSchema', () => {
|
||||
const result = CreateSupplierSchema.safeParse(validSupplier({ default_expense_account: '6200' }))
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// The web form submits untouched optional inputs as '' (Björn 2026-08-17:
|
||||
// saving with the field left blank failed "Kontonummer måste vara 4 siffror").
|
||||
it('treats empty-string expense account as absent', () => {
|
||||
const result = CreateSupplierSchema.safeParse(validSupplier({ default_expense_account: '' }))
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.default_expense_account).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats empty-string email as absent', () => {
|
||||
const result = CreateSupplierSchema.safeParse(validSupplier({ email: '' }))
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.email).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
@@ -2206,6 +2224,39 @@ describe('UpdateSupplierSchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
// Update routes pass fields straight into .update(), where undefined keys
|
||||
// are dropped (unchanged) and null writes NULL. An empty string from a
|
||||
// cleared form field must therefore become null, or clearing does nothing.
|
||||
it('maps empty-string expense account to null so clearing persists', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.default_expense_account).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps empty-string email to null so clearing persists', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ email: '' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.email).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves omitted fields absent', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ name: 'New Supplier' })
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect('default_expense_account' in result.data).toBe(false)
|
||||
expect('email' in result.data).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('still rejects a malformed expense account on update', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '54' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid expense account format', () => {
|
||||
const result = UpdateSupplierSchema.safeParse({ default_expense_account: '40' })
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
+36
-3
@@ -933,10 +933,25 @@ export const UpdateCustomerSchema = z.object({
|
||||
// Supplier schemas
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Optional field where an empty or whitespace-only string means "not set".
|
||||
*
|
||||
* HTML forms submit untouched inputs as '', which a format-validated
|
||||
* `.optional()` field would reject ('' is a present string, so it hits the
|
||||
* format rule). Same normalization as `optString` in
|
||||
* lib/pending-operations/schemas/create-supplier.ts.
|
||||
*/
|
||||
function emptyStringAsUndefined<T extends z.ZodTypeAny>(inner: T) {
|
||||
return z.preprocess(
|
||||
(v) => (typeof v === 'string' && v.trim() === '' ? undefined : v),
|
||||
inner.optional(),
|
||||
)
|
||||
}
|
||||
|
||||
export const CreateSupplierSchema = z.object({
|
||||
name: z.string().min(1, 'Supplier name is required'),
|
||||
supplier_type: SupplierTypeSchema,
|
||||
email: z.string().email('Invalid email address').optional(),
|
||||
email: emptyStringAsUndefined(z.string().email('Invalid email address')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
@@ -952,13 +967,31 @@ export const CreateSupplierSchema = z.object({
|
||||
bic: z.string().optional(),
|
||||
clearing_number: z.string().optional(),
|
||||
account_number: z.string().optional(),
|
||||
default_expense_account: accountNumber.optional(),
|
||||
default_expense_account: emptyStringAsUndefined(accountNumber),
|
||||
default_payment_terms: z.number().int().positive().optional(),
|
||||
default_currency: CurrencySchema.nullable().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateSupplierSchema = CreateSupplierSchema.partial()
|
||||
/**
|
||||
* Optional field where an empty or whitespace-only string means "clear it".
|
||||
*
|
||||
* Update routes pass validated fields straight into `.update({...})`, where
|
||||
* undefined keys are dropped by supabase-js (column left unchanged) and null
|
||||
* writes NULL. So on update an empty string from a cleared form field must
|
||||
* become null, not undefined, or clearing would silently do nothing.
|
||||
*/
|
||||
function emptyStringAsNull<T extends z.ZodTypeAny>(inner: T) {
|
||||
return z.preprocess(
|
||||
(v) => (typeof v === 'string' && v.trim() === '' ? null : v),
|
||||
inner.nullable().optional(),
|
||||
)
|
||||
}
|
||||
|
||||
export const UpdateSupplierSchema = CreateSupplierSchema.partial().extend({
|
||||
email: emptyStringAsNull(z.string().email('Invalid email address')),
|
||||
default_expense_account: emptyStringAsNull(accountNumber),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Supplier invoice schemas
|
||||
|
||||
@@ -47,6 +47,20 @@ describe('generateOpenApiSpec request contracts', () => {
|
||||
expect(schema?.required).toContain('customer_id')
|
||||
})
|
||||
|
||||
it('renders a z.preprocess field by its output schema and keeps it optional', () => {
|
||||
// CreateSupplierSchema wraps email and default_expense_account in a
|
||||
// preprocess pipe (empty string means absent). The callable sits on the
|
||||
// pipe's input side, so describing the input would yield a required
|
||||
// untyped field; the spec must show the output schema and not require it.
|
||||
const op = operation('/api/v1/companies/{companyId}/suppliers', 'post')
|
||||
const schema = op.requestBody?.content['application/json']?.schema
|
||||
expect(schema?.properties?.email).toEqual({ type: 'string' })
|
||||
expect(schema?.properties?.default_expense_account).toEqual({ type: 'string' })
|
||||
expect(schema?.required).toContain('name')
|
||||
expect(schema?.required).not.toContain('email')
|
||||
expect(schema?.required).not.toContain('default_expense_account')
|
||||
})
|
||||
|
||||
it('renders multipart z.unknown() parts as binary file parts', () => {
|
||||
const op = operation('/api/v1/companies/{companyId}/documents', 'post')
|
||||
const schema = op.requestBody?.content['multipart/form-data']?.schema
|
||||
|
||||
+13
-7
@@ -292,11 +292,16 @@ function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
|
||||
}
|
||||
}
|
||||
// `.transform()` / `.pipe()` wrappers: describe the INPUT side, which is
|
||||
// what an API caller must send.
|
||||
// what an API caller must send. `z.preprocess()` is the mirror image:
|
||||
// its input side IS the callable (a ZodTransform, no describable type),
|
||||
// and the schema the cleaned value must satisfy sits on the output side.
|
||||
case 'pipe':
|
||||
case 'ZodPipeline': {
|
||||
const input = (def as { in?: ZodTypeAny }).in
|
||||
return input ? zodToJsonSchema(input) : {}
|
||||
const pipeDef = def as { in?: ZodTypeAny; out?: ZodTypeAny }
|
||||
const inDef = (pipeDef.in as unknown as { _def?: { type?: string; typeName?: string } } | undefined)?._def
|
||||
const inDisc = inDef?.type ?? inDef?.typeName ?? ''
|
||||
const side = ['transform', 'ZodEffects'].includes(inDisc) ? pipeDef.out : pipeDef.in
|
||||
return side ? zodToJsonSchema(side) : {}
|
||||
}
|
||||
case 'effects':
|
||||
case 'ZodEffects': {
|
||||
@@ -310,10 +315,11 @@ function zodToJsonSchema(schema: ZodTypeAny): JsonSchema {
|
||||
const required: string[] = []
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
properties[key] = zodToJsonSchema(value)
|
||||
const valueDef = (value as unknown as { _def: { typeName?: string; type?: string } })._def
|
||||
const valueDisc = valueDef.type ?? valueDef.typeName ?? ''
|
||||
// Optional and defaulted fields may be omitted by the caller.
|
||||
const mayOmit = ['optional', 'ZodOptional', 'default', 'ZodDefault'].includes(valueDisc)
|
||||
// A field may be omitted exactly when the schema accepts undefined:
|
||||
// covers optional and defaulted fields, and wrappers that only carry
|
||||
// optionality inside (e.g. a preprocess pipe over `.optional()`),
|
||||
// which a top-level discriminator check misclassifies as required.
|
||||
const mayOmit = value.safeParse(undefined).success
|
||||
if (!mayOmit) {
|
||||
required.push(key)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user