From 1eee1f9415243acec6b70fa05af0d1804567466a Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 9 May 2026 22:44:09 +0200 Subject: [PATCH] fix(timetable): align Zod decimal fields with backend wire format (decimal-as-string per Laravel cast) (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod schema drift. The prompt's hypothesis (enum {value, label} mismatch) was incorrect — the schema already uses the enumLabel() wrapper for every enum field. The actual drift is decimal-cast columns: Laravel serialises `decimal(N,M)` columns as strings to preserve precision, but the schema expected numbers, so the very first response triggered a ZodError. Affected fields, all on `artist_engagements`: fee_amount decimal(10,2) → wire `"11503.58"`, schema was z.number() buma_percentage decimal(5,2) → wire `"7.00"`, schema was z.number() vat_percentage decimal(5,2) → wire `"21.00"`, schema was z.number() deposit_percentage decimal(5,2) → wire `"…"`, schema was z.number() Backend has no explicit `decimal:N` cast on these columns (api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers the enums + booleans + dates + integers, but skips decimals). Per the strategic decision (frontend adapts, backend stays): - schemas/timetable.ts: four fields → z.string().nullable() - types/timetable.ts: matching ArtistEngagement interface fields → `string | null` - PerformancePopover.vue:129: only consumer doing arithmetic on a decimal field; coerce at the use site via Number(...).toFixed(2). Single line. - tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts: spot-checked mocks; the two with hand-built engagement payloads flipped fee_amount/buma_percentage/vat_percentage from numbers to strings to match the new schema. No other mocks needed updating. The {value, label} enum wrapper claim in the prompt was specifically debunked in Phase A — every consumer (Wachtrij, PerformanceBlock, WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry) already uses .value/.label access against an enumLabel-wrapped schema. B6 will lock the wire-format contract with a real-API fixture regression test. All 397 tests still pass; typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/timetable/PerformancePopover.vue | 2 +- apps/app/src/schemas/timetable.ts | 13 +++++++++---- apps/app/src/types/timetable.ts | 14 ++++++++++---- apps/app/tests/a11y/axe.test.ts | 2 +- apps/app/tests/component/PerformanceBlock.test.ts | 6 +++--- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/app/src/components/timetable/PerformancePopover.vue b/apps/app/src/components/timetable/PerformancePopover.vue index 018141d5..627991d4 100644 --- a/apps/app/src/components/timetable/PerformancePopover.vue +++ b/apps/app/src/components/timetable/PerformancePopover.vue @@ -126,7 +126,7 @@ function close(): void { >
Fee - €{{ (engagement.fee_amount ?? 0).toFixed(2) }} + €{{ Number(engagement.fee_amount ?? 0).toFixed(2) }}
Buma diff --git a/apps/app/src/schemas/timetable.ts b/apps/app/src/schemas/timetable.ts index cb4bced8..e4a70e30 100644 --- a/apps/app/src/schemas/timetable.ts +++ b/apps/app/src/schemas/timetable.ts @@ -110,18 +110,23 @@ export const artistEngagementSchema = z.object({ }) .optional(), booking_status: enumLabel(artistEngagementStatusSchema), - fee_amount: z.number().nullable(), + + // Decimal columns serialise as strings on the wire (Laravel default for + // `decimal(N,M)` without an explicit cast — preserves precision). The + // schema mirrors that; consumers that need arithmetic coerce via + // Number()/parseFloat at the use site. + fee_amount: z.string().nullable(), fee_currency: z.string(), fee_type: enumLabel(feeTypeSchema), buma_applicable: z.boolean(), - buma_percentage: z.number().nullable(), + buma_percentage: z.string().nullable(), buma_handled_by: enumLabel(bumaHandledBySchema), vat_applicable: z.boolean(), - vat_percentage: z.number().nullable(), + vat_percentage: z.string().nullable(), deal_breakdown: z .array(z.object({ label: z.string().optional(), amount: z.number() })) .nullable(), - deposit_percentage: z.number().nullable(), + deposit_percentage: z.string().nullable(), deposit_due_date: z.string().nullable(), balance_due_date: z.string().nullable(), payment_status: enumLabel(paymentStatusSchema), diff --git a/apps/app/src/types/timetable.ts b/apps/app/src/types/timetable.ts index 14b4688a..72fb233a 100644 --- a/apps/app/src/types/timetable.ts +++ b/apps/app/src/types/timetable.ts @@ -133,16 +133,22 @@ export interface ArtistEngagement { project_leader_id: string | null project_leader?: ArtistEngagementProjectLeader booking_status: EnumLabel - fee_amount: number | null + + /** + * Decimal-cast columns serialise as strings on the wire (Laravel default + * for `decimal(N,M)` without an explicit cast — preserves precision). + * Coerce via Number()/parseFloat at consumption sites that need arithmetic. + */ + fee_amount: string | null fee_currency: string fee_type: EnumLabel buma_applicable: boolean - buma_percentage: number | null + buma_percentage: string | null buma_handled_by: EnumLabel vat_applicable: boolean - vat_percentage: number | null + vat_percentage: string | null deal_breakdown: DealBreakdownLine[] | null - deposit_percentage: number | null + deposit_percentage: string | null deposit_due_date: string | null balance_due_date: string | null payment_status: EnumLabel diff --git a/apps/app/tests/a11y/axe.test.ts b/apps/app/tests/a11y/axe.test.ts index 37f51fa4..649e22c6 100644 --- a/apps/app/tests/a11y/axe.test.ts +++ b/apps/app/tests/a11y/axe.test.ts @@ -33,7 +33,7 @@ const engagement: Partial = { artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'], booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 }, - fee_amount: 1000, + fee_amount: '1000.00', advancing_completed_count: 3, advancing_total_count: 5, } diff --git a/apps/app/tests/component/PerformanceBlock.test.ts b/apps/app/tests/component/PerformanceBlock.test.ts index 02754202..9e1392fe 100644 --- a/apps/app/tests/component/PerformanceBlock.test.ts +++ b/apps/app/tests/component/PerformanceBlock.test.ts @@ -23,14 +23,14 @@ function makePerformance(overrides: Partial = {}): Performance { event_id: 'ev1', booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, project_leader_id: null, - fee_amount: 1000, + fee_amount: '1000.00', fee_currency: 'EUR', fee_type: { value: null, label: null }, buma_applicable: true, - buma_percentage: 7, + buma_percentage: '7.00', buma_handled_by: { value: null, label: null }, vat_applicable: true, - vat_percentage: 21, + vat_percentage: '21.00', deal_breakdown: null, deposit_percentage: null, deposit_due_date: null,