fix(timetable): align Zod decimal fields with backend wire format (decimal-as-string per Laravel cast) (B5)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:44:09 +02:00
parent 3d4bd3fc38
commit 1eee1f9415
5 changed files with 24 additions and 13 deletions

View File

@@ -126,7 +126,7 @@ function close(): void {
>
<div class="tt-popover__row">
<span>Fee</span>
<span>{{ (engagement.fee_amount ?? 0).toFixed(2) }}</span>
<span>{{ Number(engagement.fee_amount ?? 0).toFixed(2) }}</span>
</div>
<div class="tt-popover__row">
<span>Buma</span>

View File

@@ -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),

View File

@@ -133,16 +133,22 @@ export interface ArtistEngagement {
project_leader_id: string | null
project_leader?: ArtistEngagementProjectLeader
booking_status: EnumLabel<ArtistEngagementStatus>
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<FeeType>
buma_applicable: boolean
buma_percentage: number | null
buma_percentage: string | null
buma_handled_by: EnumLabel<BumaHandledBy>
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<PaymentStatus>

View File

@@ -33,7 +33,7 @@ const engagement: Partial<ArtistEngagement> = {
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,
}

View File

@@ -23,14 +23,14 @@ function makePerformance(overrides: Partial<Performance> = {}): 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,