diff --git a/DECISIONS.md b/DECISIONS.md index cd201e06..8b7eee9e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1025,3 +1025,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index 087cc00c..ccaabe7d 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -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 @@ -27,6 +28,37 @@ export default function SupplierForm({ }: SupplierFormProps) { const { canWrite } = useCanWrite() const t = useTranslations('form_supplier') + const [accounts, setAccounts] = useState([]) + + 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({
- - {t('default_account_label')} + ( +
+
+ +
+ {field.value ? ( + + ) : null} +
+ )} />
diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 6eac3ffc..205a4a41 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -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) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 4046745d..cd8ba195 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(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(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 diff --git a/lib/api/v1/__tests__/openapi-request-schemas.test.ts b/lib/api/v1/__tests__/openapi-request-schemas.test.ts index 3dcc5cf2..7cf845c2 100644 --- a/lib/api/v1/__tests__/openapi-request-schemas.test.ts +++ b/lib/api/v1/__tests__/openapi-request-schemas.test.ts @@ -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 diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index daf53608..ba5c6b56 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -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) } diff --git a/skills/accounted-api/references/core.md b/skills/accounted-api/references/core.md index ae8ad489..7c0899f6 100644 --- a/skills/accounted-api/references/core.md +++ b/skills/accounted-api/references/core.md @@ -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, - result: unknown, + result?: unknown, error: { code?: string, message?: string, details?: unknown }, started_at: string, completed_at: string, diff --git a/skills/accounted-api/references/customers.md b/skills/accounted-api/references/customers.md index 8c1e8338..708c05cb 100644 --- a/skills/accounted-api/references/customers.md +++ b/skills/accounted-api/references/customers.md @@ -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 } ``` diff --git a/skills/accounted-api/references/documents.md b/skills/accounted-api/references/documents.md index dc2f6b22..97d80e71 100644 --- a/skills/accounted-api/references/documents.md +++ b/skills/accounted-api/references/documents.md @@ -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 diff --git a/skills/accounted-api/references/employees.md b/skills/accounted-api/references/employees.md index 44cb56a9..a2ac9d0e 100644 --- a/skills/accounted-api/references/employees.md +++ b/skills/accounted-api/references/employees.md @@ -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, diff --git a/skills/accounted-api/references/reports.md b/skills/accounted-api/references/reports.md index 99c8b18d..b482ab64 100644 --- a/skills/accounted-api/references/reports.md +++ b/skills/accounted-api/references/reports.md @@ -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, diff --git a/skills/accounted-api/references/salary-runs.md b/skills/accounted-api/references/salary-runs.md index 6100d8aa..302ef177 100644 --- a/skills/accounted-api/references/salary-runs.md +++ b/skills/accounted-api/references/salary-runs.md @@ -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