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:
Jakob Wennberg
2026-08-17 10:41:03 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 2eb3441244
commit 25524e1df4
12 changed files with 209 additions and 51 deletions
+1
View File
@@ -1025,3 +1025,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-17] Full-archive direct download resurfaced as an ImportRow + small centered dialog on /import's Exportera tab (row "Komplett arkiv", hash #full-archive), not by re-mounting the orphaned components/settings/BackupDownloadForm.tsx: the form was pre-frame card styling with a duplicate cloud-backup section, while the export tab's existing SIE dialog sets the house pattern (ImportRow -> sm:max-w-md dialog). Its logic (estimate, 413 handling, last-download stamp) ported into components/import/FullArchiveDialog.tsx; the orphan and its dead settings_backup_download i18n namespace deleted. The dialog reuses FiscalYearSelector despite design.md's "legacy, no new uses" line: FyPicker is a toolbar context chip, and the SIE dialog in the same file already uses FiscalYearSelector for the identical dialog-form slot, so matching it beats introducing a third pattern. Row gated to owner/admin because GET /api/reports/full-archive enforces that role server-side; showing members a download that can only 403 helps nobody.
[2026-08-17] Article picker now overwrites the line's ROT/RUT (deduction_type + work_type) from the article's housework_type, INCLUDING clearing it when the article has none: article-defines-the-row is the established applyArticle semantic (description/price/unit already overwrite), and keeping a RUT flag when switching a row to a material article would silently claim a deduction on material (HUSFL labor-only rule). Kundkort personnummer prefill is a server-side fallback in buildInvoiceWriteData (typed > stored draft > kundkort), never a client prefill: customers.personal_number reaches the browser only as ciphertext/mask by design, so the editor just relaxes the required-mark and says where the number will come from.
[2026-08-17] Arcim's "610 bilagor i Hela historiken men 100 i räkenskapsåret" in the full-archive dialog is NOT a pagination bug: verified against prod, exactly 100 documents are linked to posted vouchers in the single (extended) fiscal year and 510 are unlinked inbox/receipt docs, which scope=all includes by design (same split as cloud backup's year-ZIPs vs Grunddata.zip). Kept the semantics, fixed two things instead: estimateArchiveSize's period branch ran one unpaginated read with one flat IN() over every entry id (undercounts past the PostgREST row cap, URL blowup past ~a few hundred ids) -> now CHILD_FK_CHUNK-chunked and fetchAllRows-paginated like writeDocuments already was; and the dialog now states per scope which document set is counted, so the gap reads as intent, not as a bug.
[2026-08-17] Supplier standardkonto empty-string fix lives in the API schemas, split by verb: '' normalizes to undefined on create (key dropped, column NULL) but to null on update, because update routes pass validated fields straight into .update() where undefined means "leave unchanged"; without the null mapping a cleared standardkonto/e-post would silently never clear. Client keeps sending '' as-is (the old email-strip hack removed), since stripping client-side would break exactly that clear path. The field itself became an AccountCombobox filtered to cost classes 4-7 (matches the agent-path expenseAccountField rule); other 4-digit numbers stay typeable, and the API still enforces format only. Standardkonto stays optional: it only prefills supplier-invoice lines, and the ledger-context suggestion covers the empty case, so requiring it (what the bug accidentally did) is wrong for the target user.
+65 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -10,9 +10,10 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, Lock } from 'lucide-react'
import { Loader2, Lock, X } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateSupplierInput } from '@/types'
import type { BASAccount, CreateSupplierInput } from '@/types'
interface SupplierFormProps {
onSubmit: (data: CreateSupplierInput) => Promise<void>
@@ -27,6 +28,37 @@ export default function SupplierForm({
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_supplier')
const [accounts, setAccounts] = useState<BASAccount[]>([])
useEffect(() => {
let cancelled = false
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
if (!res.ok) return
const { data } = await res.json()
if (!cancelled) setAccounts(data || [])
} catch {
// Without the chart the combobox still accepts a typed 4-digit number.
}
}
fetchAccounts()
return () => {
cancelled = true
}
}, [])
// The default account seeds expense lines on supplier invoices, so the
// browsable list is cost classes 4-7. Any other 4-digit number can still be
// typed in; the API only enforces the format.
const expenseAccounts = useMemo(
() => accounts.filter((a) => a.account_class >= 4 && a.account_class <= 7),
[accounts]
)
const accountNameByNumber = useMemo(
() => new Map(accounts.map((a) => [a.account_number, a.account_name])),
[accounts]
)
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
@@ -85,11 +117,10 @@ export default function SupplierForm({
},
})
// Empty strings go through as-is: the API schemas normalize them (dropped on
// create, null on update so a cleared field actually clears the column).
const onFormSubmit = (data: FormData) => {
onSubmit({
...data,
email: data.email || undefined,
})
onSubmit(data)
}
return (
@@ -241,11 +272,33 @@ export default function SupplierForm({
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="default_expense_account">{t('default_account_label')}</Label>
<Input
id="default_expense_account"
placeholder={t('default_account_placeholder')}
{...register('default_expense_account')}
<Label>{t('default_account_label')}</Label>
<Controller
name="default_expense_account"
control={control}
render={({ field }) => (
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1">
<AccountCombobox
value={field.value || ''}
accounts={expenseAccounts}
onChange={field.onChange}
selectedName={accountNameByNumber.get(field.value || '')}
/>
</div>
{field.value ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('default_account_clear')}
onClick={() => field.onChange('')}
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
)}
/>
</div>
<div className="space-y-2">
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+9 -9
View File
@@ -61,17 +61,17 @@ Request body:
```ts
{
bank_name?: string,
clearing_number: string | "",
account_number: string | "",
bankgiro: string | "",
plusgiro: string | "",
clearing_number?: string | "",
account_number?: string | "",
bankgiro?: string | "",
plusgiro?: string | "",
swish?: string,
iban: string | "",
bic: string | "",
iban?: string | "",
bic?: string | "",
contact_person?: string,
email: string | "",
email?: string | "",
phone?: string,
website: string | "",
website?: string | "",
invoice_email_texts?: {
sv?: { subject?: string, greeting?: string, body?: string, signoff?: string },
en?: { subject?: string, greeting?: string, body?: string, signoff?: string }
@@ -166,7 +166,7 @@ Response `200`:
type: string,
status: "queued" | "running" | "succeeded" | "failed" | "cancelled",
progress?: Record<string, unknown>,
result: unknown,
result?: unknown,
error: { code?: string, message?: string, details?: unknown },
started_at: string,
completed_at: string,
+2 -2
View File
@@ -118,7 +118,7 @@ Request body:
country?: string,
org_number?: string,
vat_number?: string,
personal_number: string,
personal_number?: string,
language?: "sv" | "en",
default_payment_terms?: number,
notes?: string
@@ -353,7 +353,7 @@ Bulk-create endpoint mirroring /invoices/bulk-create. Each customer is validated
Request body:
```ts
{
customers: { name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", customer_number?: string, contact_person?: string, email?: string, phone?: string, invoice_email_cc_addresses?: string[], invoice_email_bcc_addresses?: string[], address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, personal_number: string, language?: "sv" | "en", default_payment_terms?: number, notes?: string }[],
customers: { name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", customer_number?: string, contact_person?: string, email?: string, phone?: string, invoice_email_cc_addresses?: string[], invoice_email_bcc_addresses?: string[], address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, personal_number?: string, language?: "sv" | "en", default_payment_terms?: number, notes?: string }[],
all_or_nothing?: boolean
}
```
+1 -1
View File
@@ -31,7 +31,7 @@ Multipart upload of a document (PDF / image) under the BFL 7 kap retention regim
Request body (`multipart/form-data`):
```ts
{
file: string,
file?: string,
upload_source?: "file_upload" | "camera" | "email" | "api",
journal_entry_id?: string,
journal_entry_line_id?: string
+1 -1
View File
@@ -716,7 +716,7 @@ Request body:
Response `200`:
```ts
{
data: { vacation_year_closure_id: string, adjustment_entry_id: string, report: unknown },
data: { vacation_year_closure_id: string, adjustment_entry_id: string, report?: unknown },
meta: {
request_id: string,
api_version: string,
+12 -12
View File
@@ -28,7 +28,7 @@ Returns the customer-receivable ledger as of `as_of_date` (defaults to today). E
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -62,7 +62,7 @@ Returns the annual avgifter basis per employee for `year`, summed across booked
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -96,7 +96,7 @@ Returns assets / liabilities / equity grouped into BAS sections, with the period
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -130,7 +130,7 @@ Validates that the target period's opening balances (IB) equal the prior period'
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -165,7 +165,7 @@ Returns every posted journal line in the period grouped by account, with opening
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -199,7 +199,7 @@ Returns the period's revenue and expenses grouped by BAS class with subtotals (g
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -234,7 +234,7 @@ Returns every committed journal entry in the period with its voucher number, dat
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -267,7 +267,7 @@ Returns revenue + expenses + net result per calendar month inside the fiscal per
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -303,7 +303,7 @@ Returns per-employee salary figures (gross / tax / net / avgifter / vacation acc
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -361,7 +361,7 @@ Returns the supplier-payable ledger as of `as_of_date` (defaults to today). Each
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -435,7 +435,7 @@ Returns per-employee semesterlöneskuld balances as of year-end based on their v
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -471,7 +471,7 @@ Computes momsdeklaration rutor for the given period_type / year / period. The re
Response `200`:
```ts
{
data: unknown,
data?: unknown,
meta: {
request_id: string,
api_version: string,
@@ -96,7 +96,7 @@ Response `200`:
booked_at: string,
created_at: string,
notes: string,
calculation_params: unknown,
calculation_params?: unknown,
updated_at: string
},
meta: {
@@ -151,7 +151,7 @@ Response `200`:
vacation_entry_id: string,
agi_generated_at: string,
agi_submitted_at: string,
calculation_params: unknown,
calculation_params?: unknown,
approved_by: string,
approved_at: string,
paid_at: string,
@@ -218,7 +218,7 @@ Response `200`:
vacation_entry_id: string,
agi_generated_at: string,
agi_submitted_at: string,
calculation_params: unknown,
calculation_params?: unknown,
approved_by: string,
approved_at: string,
paid_at: string,
@@ -549,7 +549,7 @@ Response `200`:
ytd_gross: number,
ytd_tax: number,
ytd_net: number,
calculation_breakdown: unknown,
calculation_breakdown?: unknown,
line_items: { salary_line_item_id: string, item_type: string, description: string, quantity: number, unit_price: number, amount: number, is_taxable: boolean, is_avgift_basis: boolean, is_vacation_basis: boolean, is_gross_deduction: boolean, is_net_deduction: boolean, account_number: string, sort_order: number }[],
created_at: string,
updated_at: string