@topy-ai/maggie 0.7.52 → 0.7.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -54
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +17 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -0,0 +1,1699 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "schemaVersion": "maggiedash-booking-rules.v1",
4
+ "title": "MaggieDash booking rule contracts and shared test vectors",
5
+ "description": "Pure rules every booking host implements the same way. Run `maggie booking rules-vectors` against the reference, or `--host-command` against the host's implementation (one {rule, input} JSON line in, the result JSON out). Expected objects are partial: only the listed fields are compared. Money is integer minor units; instants are UTC ISO-8601; local wall times use the venue's IANA zone.",
6
+ "rules": [
7
+ {
8
+ "id": "manage_action",
9
+ "why": "99edd9: a manage link cancelled a booking that had started or was still on hold",
10
+ "vectors": [
11
+ {
12
+ "name": "client cancels a confirmed booking days ahead",
13
+ "input": {
14
+ "status": "confirmed",
15
+ "action": "cancel",
16
+ "actor": "client",
17
+ "startAt": "2026-10-20T10:00:00Z",
18
+ "now": "2026-10-17T10:00:00Z",
19
+ "policy": {
20
+ "mode": "window",
21
+ "windowHours": 24
22
+ }
23
+ },
24
+ "expect": {
25
+ "allowed": true,
26
+ "reason": "ok"
27
+ }
28
+ },
29
+ {
30
+ "name": "client cannot cancel after arrival",
31
+ "input": {
32
+ "status": "arrived",
33
+ "action": "cancel",
34
+ "actor": "client",
35
+ "startAt": "2026-10-20T10:00:00Z",
36
+ "now": "2026-10-20T09:55:00Z"
37
+ },
38
+ "expect": {
39
+ "allowed": false,
40
+ "reason": "in_progress"
41
+ }
42
+ },
43
+ {
44
+ "name": "client cannot cancel a booking still on hold",
45
+ "input": {
46
+ "status": "held",
47
+ "action": "cancel",
48
+ "actor": "client",
49
+ "startAt": "2026-10-20T10:00:00Z",
50
+ "now": "2026-10-17T10:00:00Z"
51
+ },
52
+ "expect": {
53
+ "allowed": false,
54
+ "reason": "not_confirmed"
55
+ }
56
+ },
57
+ {
58
+ "name": "client cannot reschedule after the start",
59
+ "input": {
60
+ "status": "confirmed",
61
+ "action": "reschedule",
62
+ "actor": "client",
63
+ "startAt": "2026-10-20T10:00:00Z",
64
+ "now": "2026-10-20T10:10:00Z"
65
+ },
66
+ "expect": {
67
+ "allowed": false,
68
+ "reason": "started"
69
+ }
70
+ },
71
+ {
72
+ "name": "free-cancellation policy still stops at the start",
73
+ "input": {
74
+ "status": "confirmed",
75
+ "action": "cancel",
76
+ "actor": "client",
77
+ "startAt": "2026-10-20T10:00:00Z",
78
+ "now": "2026-10-20T10:01:00Z",
79
+ "policy": {
80
+ "mode": "free"
81
+ }
82
+ },
83
+ "expect": {
84
+ "allowed": false,
85
+ "reason": "started"
86
+ }
87
+ },
88
+ {
89
+ "name": "inside the cancellation window the client must contact the business",
90
+ "input": {
91
+ "status": "confirmed",
92
+ "action": "cancel",
93
+ "actor": "client",
94
+ "startAt": "2026-10-20T10:00:00Z",
95
+ "now": "2026-10-20T05:00:00Z",
96
+ "policy": {
97
+ "mode": "window",
98
+ "windowHours": 24
99
+ }
100
+ },
101
+ "expect": {
102
+ "allowed": false,
103
+ "reason": "inside_window"
104
+ }
105
+ },
106
+ {
107
+ "name": "business may cancel a booking in service",
108
+ "input": {
109
+ "status": "in_service",
110
+ "action": "cancel",
111
+ "actor": "business",
112
+ "startAt": "2026-10-20T10:00:00Z",
113
+ "now": "2026-10-20T10:20:00Z"
114
+ },
115
+ "expect": {
116
+ "allowed": true
117
+ }
118
+ },
119
+ {
120
+ "name": "nobody reschedules a booking in service",
121
+ "input": {
122
+ "status": "in_service",
123
+ "action": "reschedule",
124
+ "actor": "business",
125
+ "startAt": "2026-10-20T10:00:00Z",
126
+ "now": "2026-10-20T10:20:00Z"
127
+ },
128
+ "expect": {
129
+ "allowed": false,
130
+ "reason": "in_progress"
131
+ }
132
+ },
133
+ {
134
+ "name": "completed bookings are closed",
135
+ "input": {
136
+ "status": "completed",
137
+ "action": "cancel",
138
+ "actor": "client",
139
+ "startAt": "2026-10-20T10:00:00Z",
140
+ "now": "2026-10-21T10:00:00Z"
141
+ },
142
+ "expect": {
143
+ "allowed": false,
144
+ "reason": "closed"
145
+ }
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "id": "late_payment",
151
+ "why": "3873f8: payment completed after the hold expired left a paid client without a booking",
152
+ "vectors": [
153
+ {
154
+ "name": "normal payment confirms",
155
+ "input": {
156
+ "bookingStatus": "pending_payment",
157
+ "paidMinor": 2500
158
+ },
159
+ "expect": {
160
+ "outcome": "confirm",
161
+ "newStatus": "confirmed"
162
+ }
163
+ },
164
+ {
165
+ "name": "late payment, time still free: reinstate",
166
+ "input": {
167
+ "bookingStatus": "expired",
168
+ "slotFree": true,
169
+ "paidMinor": 2500
170
+ },
171
+ "expect": {
172
+ "outcome": "reinstate",
173
+ "newStatus": "confirmed",
174
+ "refundMinor": 0
175
+ }
176
+ },
177
+ {
178
+ "name": "late payment, time taken: refund in full and tell the client",
179
+ "input": {
180
+ "bookingStatus": "expired",
181
+ "slotFree": false,
182
+ "paidMinor": 2500
183
+ },
184
+ "expect": {
185
+ "outcome": "refund",
186
+ "newStatus": "expired",
187
+ "refundMinor": 2500,
188
+ "notify": [
189
+ "client.payment_refunded_slot_taken",
190
+ "business.late_payment_refunded"
191
+ ]
192
+ }
193
+ },
194
+ {
195
+ "name": "repeated webhook is a no-op",
196
+ "input": {
197
+ "bookingStatus": "confirmed",
198
+ "paidMinor": 2500
199
+ },
200
+ "expect": {
201
+ "outcome": "duplicate_event",
202
+ "refundMinor": 0
203
+ }
204
+ },
205
+ {
206
+ "name": "payment after the client cancelled is refunded",
207
+ "input": {
208
+ "bookingStatus": "cancelled",
209
+ "cancelledBy": "client",
210
+ "slotFree": true,
211
+ "paidMinor": 4000
212
+ },
213
+ "expect": {
214
+ "outcome": "refund",
215
+ "refundMinor": 4000
216
+ }
217
+ }
218
+ ]
219
+ },
220
+ {
221
+ "id": "schedule_to_utc",
222
+ "why": "2076c0: time off was converted with the browser's zone",
223
+ "vectors": [
224
+ {
225
+ "name": "summer time off in a UK venue from a US browser",
226
+ "input": {
227
+ "local": "2026-07-10T09:00",
228
+ "venueTz": "Europe/London",
229
+ "browserTz": "America/New_York"
230
+ },
231
+ "expect": {
232
+ "utc": "2026-07-10T08:00:00Z",
233
+ "ambiguous": false
234
+ }
235
+ },
236
+ {
237
+ "name": "winter time off",
238
+ "input": {
239
+ "local": "2026-01-10T09:00",
240
+ "venueTz": "Europe/London",
241
+ "browserTz": "Asia/Tokyo"
242
+ },
243
+ "expect": {
244
+ "utc": "2026-01-10T09:00:00Z"
245
+ }
246
+ },
247
+ {
248
+ "name": "a time skipped by the spring change is rejected",
249
+ "input": {
250
+ "local": "2026-03-29T01:30",
251
+ "venueTz": "Europe/London"
252
+ },
253
+ "expect": {
254
+ "error": "nonexistent_local_time"
255
+ }
256
+ },
257
+ {
258
+ "name": "a repeated autumn time uses the first occurrence and says so",
259
+ "input": {
260
+ "local": "2026-10-25T01:30",
261
+ "venueTz": "Europe/London"
262
+ },
263
+ "expect": {
264
+ "utc": "2026-10-25T00:30:00Z",
265
+ "ambiguous": true
266
+ }
267
+ }
268
+ ]
269
+ },
270
+ {
271
+ "id": "money_minor",
272
+ "why": "27a77a: bigint prices arrived as strings and broke totals",
273
+ "vectors": [
274
+ {
275
+ "name": "pg bigint string",
276
+ "input": {
277
+ "value": "4500"
278
+ },
279
+ "expect": {
280
+ "minor": 4500
281
+ }
282
+ },
283
+ {
284
+ "name": "integer",
285
+ "input": {
286
+ "value": 4500
287
+ },
288
+ "expect": {
289
+ "minor": 4500
290
+ }
291
+ },
292
+ {
293
+ "name": "decimal string is not minor units",
294
+ "input": {
295
+ "value": "45.00"
296
+ },
297
+ "expect": {
298
+ "error": "not_minor_units"
299
+ }
300
+ },
301
+ {
302
+ "name": "float is not minor units",
303
+ "input": {
304
+ "value": 45.5
305
+ },
306
+ "expect": {
307
+ "error": "not_minor_units"
308
+ }
309
+ },
310
+ {
311
+ "name": "boolean is not money",
312
+ "input": {
313
+ "value": true
314
+ },
315
+ "expect": {
316
+ "error": "not_minor_units"
317
+ }
318
+ }
319
+ ]
320
+ },
321
+ {
322
+ "id": "sale_total",
323
+ "why": "14c87e: add-ons already in the booking item price were counted twice at the till",
324
+ "vectors": [
325
+ {
326
+ "name": "add-on included in the item price is not added again",
327
+ "input": {
328
+ "items": [
329
+ {
330
+ "priceMinor": 6000,
331
+ "addOns": [
332
+ {
333
+ "priceMinor": 1500,
334
+ "includedInItemPrice": true
335
+ }
336
+ ]
337
+ }
338
+ ]
339
+ },
340
+ "expect": {
341
+ "subtotalMinor": 6000,
342
+ "totalMinor": 6000,
343
+ "amountDueMinor": 6000
344
+ }
345
+ },
346
+ {
347
+ "name": "add-on sold separately is added",
348
+ "input": {
349
+ "items": [
350
+ {
351
+ "priceMinor": 6000,
352
+ "addOns": [
353
+ {
354
+ "priceMinor": 1500
355
+ }
356
+ ]
357
+ }
358
+ ]
359
+ },
360
+ "expect": {
361
+ "subtotalMinor": 7500
362
+ }
363
+ },
364
+ {
365
+ "name": "discount, service charge, voucher and a split tip",
366
+ "input": {
367
+ "items": [
368
+ {
369
+ "priceMinor": 5000
370
+ },
371
+ {
372
+ "priceMinor": 2000,
373
+ "qty": 2
374
+ }
375
+ ],
376
+ "discountMinor": 1000,
377
+ "serviceChargePercent": 12.5,
378
+ "voucherMinor": 3000,
379
+ "tipMinor": 1000,
380
+ "tipSplit": [
381
+ {
382
+ "staffId": "a"
383
+ },
384
+ {
385
+ "staffId": "b"
386
+ }
387
+ ]
388
+ },
389
+ "expect": {
390
+ "subtotalMinor": 9000,
391
+ "serviceChargeMinor": 1000,
392
+ "totalMinor": 9000,
393
+ "voucherMinor": 3000,
394
+ "amountDueMinor": 7000,
395
+ "tipsByStaff": {
396
+ "a": 500,
397
+ "b": 500
398
+ }
399
+ }
400
+ },
401
+ {
402
+ "name": "tip remainder pennies go out one by one",
403
+ "input": {
404
+ "items": [
405
+ {
406
+ "priceMinor": 1000
407
+ }
408
+ ],
409
+ "tipMinor": 1001,
410
+ "tipSplit": [
411
+ {
412
+ "staffId": "c"
413
+ },
414
+ {
415
+ "staffId": "a"
416
+ },
417
+ {
418
+ "staffId": "b"
419
+ }
420
+ ]
421
+ },
422
+ "expect": {
423
+ "tipsByStaff": {
424
+ "a": 334,
425
+ "b": 334,
426
+ "c": 333
427
+ }
428
+ }
429
+ },
430
+ {
431
+ "name": "service charge rounds half up",
432
+ "input": {
433
+ "items": [
434
+ {
435
+ "priceMinor": 3333
436
+ }
437
+ ],
438
+ "serviceChargePercent": 12.5
439
+ },
440
+ "expect": {
441
+ "serviceChargeMinor": 417,
442
+ "totalMinor": 3750
443
+ }
444
+ },
445
+ {
446
+ "name": "a package covers part of the sale",
447
+ "input": {
448
+ "items": [
449
+ {
450
+ "priceMinor": 6000
451
+ }
452
+ ],
453
+ "voucherMinor": 1000,
454
+ "packageCoveredMinor": 4000
455
+ },
456
+ "expect": {
457
+ "voucherMinor": 1000,
458
+ "packageMinor": 4000,
459
+ "amountDueMinor": 1000
460
+ }
461
+ }
462
+ ]
463
+ },
464
+ {
465
+ "id": "payment_policy_setup",
466
+ "why": "fd6d59: deposits could be chosen without payments connected; clients were refused at the last step",
467
+ "vectors": [
468
+ {
469
+ "name": "pay at the venue needs nothing",
470
+ "input": {
471
+ "policy": "venue",
472
+ "paymentsConnected": false
473
+ },
474
+ "expect": {
475
+ "allowed": true
476
+ }
477
+ },
478
+ {
479
+ "name": "deposit without payments connected",
480
+ "input": {
481
+ "policy": "deposit",
482
+ "paymentsConnected": false
483
+ },
484
+ "expect": {
485
+ "allowed": false,
486
+ "reason": "payments_not_connected"
487
+ }
488
+ },
489
+ {
490
+ "name": "full payment while charges are disabled",
491
+ "input": {
492
+ "policy": "full",
493
+ "paymentsConnected": true,
494
+ "chargesEnabled": false
495
+ },
496
+ "expect": {
497
+ "allowed": false,
498
+ "reason": "charges_not_enabled"
499
+ }
500
+ },
501
+ {
502
+ "name": "deposit with a live account",
503
+ "input": {
504
+ "policy": "deposit",
505
+ "paymentsConnected": true,
506
+ "chargesEnabled": true
507
+ },
508
+ "expect": {
509
+ "allowed": true
510
+ }
511
+ }
512
+ ]
513
+ },
514
+ {
515
+ "id": "reminders_due",
516
+ "why": "ff4665: only a 24 h reminder existed; reminders need several offsets and a once-only guard",
517
+ "vectors": [
518
+ {
519
+ "name": "24 h reminder",
520
+ "input": {
521
+ "startAt": "2026-10-20T15:00:00Z",
522
+ "createdAt": "2026-10-10T09:00:00Z",
523
+ "now": "2026-10-19T15:30:00Z",
524
+ "offsetsHours": [
525
+ 24,
526
+ 2
527
+ ],
528
+ "sent": []
529
+ },
530
+ "expect": {
531
+ "due": [
532
+ "24h"
533
+ ],
534
+ "skipped": []
535
+ }
536
+ },
537
+ {
538
+ "name": "2 h reminder after the 24 h one",
539
+ "input": {
540
+ "startAt": "2026-10-20T15:00:00Z",
541
+ "createdAt": "2026-10-10T09:00:00Z",
542
+ "now": "2026-10-20T13:10:00Z",
543
+ "offsetsHours": [
544
+ 24,
545
+ 2
546
+ ],
547
+ "sent": [
548
+ "24h"
549
+ ]
550
+ },
551
+ "expect": {
552
+ "due": [
553
+ "2h"
554
+ ],
555
+ "skipped": []
556
+ }
557
+ },
558
+ {
559
+ "name": "a missed 24 h reminder is not sent late",
560
+ "input": {
561
+ "startAt": "2026-10-20T15:00:00Z",
562
+ "createdAt": "2026-10-10T09:00:00Z",
563
+ "now": "2026-10-20T13:10:00Z",
564
+ "offsetsHours": [
565
+ 24,
566
+ 2
567
+ ],
568
+ "sent": []
569
+ },
570
+ "expect": {
571
+ "due": [
572
+ "2h"
573
+ ],
574
+ "skipped": [
575
+ "24h"
576
+ ]
577
+ }
578
+ },
579
+ {
580
+ "name": "booked 3 h ahead: no 24 h reminder",
581
+ "input": {
582
+ "startAt": "2026-10-20T15:00:00Z",
583
+ "createdAt": "2026-10-20T12:00:00Z",
584
+ "now": "2026-10-20T13:05:00Z",
585
+ "offsetsHours": [
586
+ 24,
587
+ 2
588
+ ],
589
+ "sent": []
590
+ },
591
+ "expect": {
592
+ "due": [
593
+ "2h"
594
+ ],
595
+ "skipped": [
596
+ "24h"
597
+ ]
598
+ }
599
+ },
600
+ {
601
+ "name": "nothing after the start",
602
+ "input": {
603
+ "startAt": "2026-10-20T15:00:00Z",
604
+ "createdAt": "2026-10-10T09:00:00Z",
605
+ "now": "2026-10-20T15:01:00Z",
606
+ "offsetsHours": [
607
+ 24,
608
+ 2
609
+ ],
610
+ "sent": [
611
+ "24h"
612
+ ]
613
+ },
614
+ "expect": {
615
+ "due": []
616
+ }
617
+ },
618
+ {
619
+ "name": "cancelled bookings get no reminder",
620
+ "input": {
621
+ "status": "cancelled",
622
+ "startAt": "2026-10-20T15:00:00Z",
623
+ "createdAt": "2026-10-10T09:00:00Z",
624
+ "now": "2026-10-19T15:30:00Z"
625
+ },
626
+ "expect": {
627
+ "due": []
628
+ }
629
+ },
630
+ {
631
+ "name": "offsets are per business",
632
+ "input": {
633
+ "startAt": "2026-10-20T15:00:00Z",
634
+ "createdAt": "2026-10-10T09:00:00Z",
635
+ "now": "2026-10-18T15:30:00Z",
636
+ "offsetsHours": [
637
+ 48,
638
+ 1
639
+ ],
640
+ "sent": []
641
+ },
642
+ "expect": {
643
+ "due": [
644
+ "48h"
645
+ ]
646
+ }
647
+ }
648
+ ]
649
+ },
650
+ {
651
+ "id": "notification_plan",
652
+ "why": "ff4665: the business got no notice of a client reschedule; the cancel template always said the client cancelled",
653
+ "vectors": [
654
+ {
655
+ "name": "client cancels",
656
+ "input": {
657
+ "event": "cancelled",
658
+ "actor": "client"
659
+ },
660
+ "expect": {
661
+ "templates": {
662
+ "client": "booking.cancelled.by_client",
663
+ "business": "business.booking_cancelled.by_client"
664
+ }
665
+ }
666
+ },
667
+ {
668
+ "name": "business cancels",
669
+ "input": {
670
+ "event": "cancelled",
671
+ "actor": "business"
672
+ },
673
+ "expect": {
674
+ "templates": {
675
+ "client": "booking.cancelled.by_business"
676
+ }
677
+ }
678
+ },
679
+ {
680
+ "name": "client reschedules: the business is told",
681
+ "input": {
682
+ "event": "rescheduled",
683
+ "actor": "client"
684
+ },
685
+ "expect": {
686
+ "templates": {
687
+ "client": "booking.rescheduled.by_client",
688
+ "business": "business.booking_rescheduled.by_client"
689
+ }
690
+ }
691
+ },
692
+ {
693
+ "name": "system expiry",
694
+ "input": {
695
+ "event": "cancelled",
696
+ "actor": "system"
697
+ },
698
+ "expect": {
699
+ "templates": {
700
+ "client": "booking.cancelled.by_system",
701
+ "business": "business.booking_cancelled.by_system"
702
+ }
703
+ }
704
+ }
705
+ ]
706
+ },
707
+ {
708
+ "id": "ics_text",
709
+ "why": "44894d: escaping ';' did nothing; .ics must escape and fold per RFC 5545",
710
+ "vectors": [
711
+ {
712
+ "name": "semicolon, comma, backslash and newline",
713
+ "input": {
714
+ "text": "Lash lift; tint, \\ brow\nnotes"
715
+ },
716
+ "expect": {
717
+ "escaped": "Lash lift\\; tint\\, \\\\ brow\\nnotes"
718
+ }
719
+ },
720
+ {
721
+ "name": "lines fold at 75 octets",
722
+ "input": {
723
+ "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
724
+ },
725
+ "expect": {
726
+ "lines": [
727
+ "SUMMARY:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
728
+ " aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
729
+ ]
730
+ }
731
+ }
732
+ ]
733
+ },
734
+ {
735
+ "id": "rota_week",
736
+ "why": "7589df: alternate-week rotas",
737
+ "vectors": [
738
+ {
739
+ "name": "second week of a two-week rota",
740
+ "input": {
741
+ "anchor": "2026-10-05",
742
+ "date": "2026-10-14",
743
+ "weeks": 2
744
+ },
745
+ "expect": {
746
+ "week": 1
747
+ }
748
+ },
749
+ {
750
+ "name": "back to the first week",
751
+ "input": {
752
+ "anchor": "2026-10-05",
753
+ "date": "2026-10-19",
754
+ "weeks": 2
755
+ },
756
+ "expect": {
757
+ "week": 0
758
+ }
759
+ },
760
+ {
761
+ "name": "anchor mid-week counts from its Monday",
762
+ "input": {
763
+ "anchor": "2026-10-07",
764
+ "date": "2026-10-12",
765
+ "weeks": 2
766
+ },
767
+ "expect": {
768
+ "week": 1
769
+ }
770
+ }
771
+ ]
772
+ },
773
+ {
774
+ "id": "plan_slots",
775
+ "why": "7589df: split hours, per-staff durations, any-staff choice, gaps, groups, rotas and DST with shared vectors",
776
+ "vectors": [
777
+ {
778
+ "name": "split opening hours keep both ranges",
779
+ "input": {
780
+ "timezone": "Europe/London",
781
+ "date": "2026-10-14",
782
+ "slotMinutes": 60,
783
+ "hours": [
784
+ [
785
+ "09:00",
786
+ "12:00"
787
+ ],
788
+ [
789
+ "13:00",
790
+ "15:00"
791
+ ]
792
+ ],
793
+ "service": {
794
+ "id": "svc",
795
+ "durationMinutes": 60
796
+ },
797
+ "staff": [
798
+ {
799
+ "id": "s1"
800
+ }
801
+ ]
802
+ },
803
+ "expect": {
804
+ "starts": [
805
+ "09:00",
806
+ "10:00",
807
+ "11:00",
808
+ "13:00",
809
+ "14:00"
810
+ ]
811
+ }
812
+ },
813
+ {
814
+ "name": "per-staff duration and the least busy person first",
815
+ "input": {
816
+ "timezone": "Europe/London",
817
+ "date": "2026-10-14",
818
+ "slotMinutes": 30,
819
+ "hours": [
820
+ [
821
+ "09:00",
822
+ "11:00"
823
+ ]
824
+ ],
825
+ "service": {
826
+ "id": "svc",
827
+ "durationMinutes": 60
828
+ },
829
+ "staff": [
830
+ {
831
+ "id": "a",
832
+ "sortOrder": 1,
833
+ "bookingsToday": 2,
834
+ "durations": {
835
+ "svc": 90
836
+ }
837
+ },
838
+ {
839
+ "id": "b",
840
+ "sortOrder": 2,
841
+ "bookingsToday": 0
842
+ }
843
+ ]
844
+ },
845
+ "expect": {
846
+ "starts": [
847
+ "09:00",
848
+ "09:30",
849
+ "10:00"
850
+ ],
851
+ "slots": [
852
+ {
853
+ "start": "09:00",
854
+ "utc": "2026-10-14T08:00:00Z",
855
+ "staff": [
856
+ "b"
857
+ ]
858
+ },
859
+ {
860
+ "start": "09:30",
861
+ "utc": "2026-10-14T08:30:00Z",
862
+ "staff": [
863
+ "b"
864
+ ]
865
+ },
866
+ {
867
+ "start": "10:00",
868
+ "utc": "2026-10-14T09:00:00Z",
869
+ "staff": [
870
+ "b"
871
+ ]
872
+ }
873
+ ]
874
+ }
875
+ },
876
+ {
877
+ "name": "buffers keep other bookings away",
878
+ "input": {
879
+ "timezone": "Europe/London",
880
+ "date": "2026-10-14",
881
+ "slotMinutes": 15,
882
+ "hours": [
883
+ [
884
+ "09:00",
885
+ "12:00"
886
+ ]
887
+ ],
888
+ "service": {
889
+ "id": "svc",
890
+ "durationMinutes": 30,
891
+ "bufferAfter": 15
892
+ },
893
+ "staff": [
894
+ {
895
+ "id": "s1",
896
+ "busy": [
897
+ [
898
+ "2026-10-14T09:00:00Z",
899
+ "2026-10-14T10:00:00Z"
900
+ ]
901
+ ]
902
+ }
903
+ ]
904
+ },
905
+ "expect": {
906
+ "starts": [
907
+ "09:00",
908
+ "09:15",
909
+ "11:00",
910
+ "11:15",
911
+ "11:30"
912
+ ]
913
+ }
914
+ },
915
+ {
916
+ "name": "no gaps shorter than 30 minutes",
917
+ "input": {
918
+ "timezone": "Europe/London",
919
+ "date": "2026-10-14",
920
+ "slotMinutes": 15,
921
+ "hours": [
922
+ [
923
+ "09:00",
924
+ "12:00"
925
+ ]
926
+ ],
927
+ "minGapMinutes": 30,
928
+ "service": {
929
+ "id": "svc",
930
+ "durationMinutes": 30
931
+ },
932
+ "staff": [
933
+ {
934
+ "id": "s1",
935
+ "busy": [
936
+ [
937
+ "2026-10-14T09:00:00Z",
938
+ "2026-10-14T10:00:00Z"
939
+ ]
940
+ ]
941
+ }
942
+ ]
943
+ },
944
+ "expect": {
945
+ "starts": [
946
+ "09:00",
947
+ "09:30",
948
+ "11:00",
949
+ "11:30"
950
+ ]
951
+ }
952
+ },
953
+ {
954
+ "name": "clock change day",
955
+ "input": {
956
+ "timezone": "Europe/London",
957
+ "date": "2026-03-29",
958
+ "slotMinutes": 60,
959
+ "hours": [
960
+ [
961
+ "09:00",
962
+ "11:00"
963
+ ]
964
+ ],
965
+ "service": {
966
+ "id": "svc",
967
+ "durationMinutes": 60
968
+ },
969
+ "staff": [
970
+ {
971
+ "id": "s1"
972
+ }
973
+ ]
974
+ },
975
+ "expect": {
976
+ "slots": [
977
+ {
978
+ "start": "09:00",
979
+ "utc": "2026-03-29T08:00:00Z",
980
+ "staff": [
981
+ "s1"
982
+ ]
983
+ },
984
+ {
985
+ "start": "10:00",
986
+ "utc": "2026-03-29T09:00:00Z",
987
+ "staff": [
988
+ "s1"
989
+ ]
990
+ }
991
+ ]
992
+ }
993
+ },
994
+ {
995
+ "name": "group of three needs three free people",
996
+ "input": {
997
+ "timezone": "Europe/London",
998
+ "date": "2026-10-14",
999
+ "slotMinutes": 60,
1000
+ "hours": [
1001
+ [
1002
+ "09:00",
1003
+ "10:00"
1004
+ ]
1005
+ ],
1006
+ "groupSize": 3,
1007
+ "service": {
1008
+ "id": "svc",
1009
+ "durationMinutes": 60
1010
+ },
1011
+ "staff": [
1012
+ {
1013
+ "id": "a"
1014
+ },
1015
+ {
1016
+ "id": "b"
1017
+ },
1018
+ {
1019
+ "id": "c",
1020
+ "busy": [
1021
+ [
1022
+ "2026-10-14T08:00:00Z",
1023
+ "2026-10-14T09:00:00Z"
1024
+ ]
1025
+ ]
1026
+ }
1027
+ ]
1028
+ },
1029
+ "expect": {
1030
+ "starts": []
1031
+ }
1032
+ },
1033
+ {
1034
+ "name": "group of two",
1035
+ "input": {
1036
+ "timezone": "Europe/London",
1037
+ "date": "2026-10-14",
1038
+ "slotMinutes": 60,
1039
+ "hours": [
1040
+ [
1041
+ "09:00",
1042
+ "10:00"
1043
+ ]
1044
+ ],
1045
+ "groupSize": 2,
1046
+ "service": {
1047
+ "id": "svc",
1048
+ "durationMinutes": 60
1049
+ },
1050
+ "staff": [
1051
+ {
1052
+ "id": "a"
1053
+ },
1054
+ {
1055
+ "id": "b"
1056
+ },
1057
+ {
1058
+ "id": "c",
1059
+ "busy": [
1060
+ [
1061
+ "2026-10-14T08:00:00Z",
1062
+ "2026-10-14T09:00:00Z"
1063
+ ]
1064
+ ]
1065
+ }
1066
+ ]
1067
+ },
1068
+ "expect": {
1069
+ "slots": [
1070
+ {
1071
+ "start": "09:00",
1072
+ "utc": "2026-10-14T08:00:00Z",
1073
+ "staff": [
1074
+ "a",
1075
+ "b"
1076
+ ]
1077
+ }
1078
+ ]
1079
+ }
1080
+ },
1081
+ {
1082
+ "name": "alternate-week rota: off this week",
1083
+ "input": {
1084
+ "timezone": "Europe/London",
1085
+ "date": "2026-10-14",
1086
+ "slotMinutes": 60,
1087
+ "hours": [
1088
+ [
1089
+ "09:00",
1090
+ "12:00"
1091
+ ]
1092
+ ],
1093
+ "service": {
1094
+ "id": "svc",
1095
+ "durationMinutes": 60
1096
+ },
1097
+ "staff": [
1098
+ {
1099
+ "id": "s1",
1100
+ "rota": {
1101
+ "anchor": "2026-10-05",
1102
+ "patterns": [
1103
+ {
1104
+ "2": [
1105
+ [
1106
+ "09:00",
1107
+ "12:00"
1108
+ ]
1109
+ ]
1110
+ },
1111
+ {
1112
+ "2": []
1113
+ }
1114
+ ]
1115
+ }
1116
+ }
1117
+ ]
1118
+ },
1119
+ "expect": {
1120
+ "starts": []
1121
+ }
1122
+ },
1123
+ {
1124
+ "name": "alternate-week rota: on this week",
1125
+ "input": {
1126
+ "timezone": "Europe/London",
1127
+ "date": "2026-10-07",
1128
+ "slotMinutes": 60,
1129
+ "hours": [
1130
+ [
1131
+ "09:00",
1132
+ "12:00"
1133
+ ]
1134
+ ],
1135
+ "service": {
1136
+ "id": "svc",
1137
+ "durationMinutes": 60
1138
+ },
1139
+ "staff": [
1140
+ {
1141
+ "id": "s1",
1142
+ "rota": {
1143
+ "anchor": "2026-10-05",
1144
+ "patterns": [
1145
+ {
1146
+ "2": [
1147
+ [
1148
+ "09:00",
1149
+ "11:00"
1150
+ ]
1151
+ ]
1152
+ },
1153
+ {
1154
+ "2": []
1155
+ }
1156
+ ]
1157
+ }
1158
+ }
1159
+ ]
1160
+ },
1161
+ "expect": {
1162
+ "starts": [
1163
+ "09:00",
1164
+ "10:00"
1165
+ ]
1166
+ }
1167
+ }
1168
+ ]
1169
+ },
1170
+ {
1171
+ "id": "price_for",
1172
+ "why": "7589df: peak/off-peak price rules",
1173
+ "vectors": [
1174
+ {
1175
+ "name": "Saturday peak",
1176
+ "input": {
1177
+ "timezone": "Europe/London",
1178
+ "startUtc": "2026-10-17T10:30:00Z",
1179
+ "basePriceMinor": 5000,
1180
+ "rules": [
1181
+ {
1182
+ "name": "peak",
1183
+ "weekdays": [
1184
+ 5,
1185
+ 6
1186
+ ],
1187
+ "from": "10:00",
1188
+ "to": "18:00",
1189
+ "percent": 20
1190
+ },
1191
+ {
1192
+ "name": "off-peak",
1193
+ "weekdays": [
1194
+ 0,
1195
+ 1,
1196
+ 2,
1197
+ 3,
1198
+ 4
1199
+ ],
1200
+ "from": "09:00",
1201
+ "to": "12:00",
1202
+ "priceMinor": 4000
1203
+ }
1204
+ ]
1205
+ },
1206
+ "expect": {
1207
+ "priceMinor": 6000,
1208
+ "rule": "peak"
1209
+ }
1210
+ },
1211
+ {
1212
+ "name": "weekday morning off-peak",
1213
+ "input": {
1214
+ "timezone": "Europe/London",
1215
+ "startUtc": "2026-10-14T08:30:00Z",
1216
+ "basePriceMinor": 5000,
1217
+ "rules": [
1218
+ {
1219
+ "name": "peak",
1220
+ "weekdays": [
1221
+ 5,
1222
+ 6
1223
+ ],
1224
+ "from": "10:00",
1225
+ "to": "18:00",
1226
+ "percent": 20
1227
+ },
1228
+ {
1229
+ "name": "off-peak",
1230
+ "weekdays": [
1231
+ 0,
1232
+ 1,
1233
+ 2,
1234
+ 3,
1235
+ 4
1236
+ ],
1237
+ "from": "09:00",
1238
+ "to": "12:00",
1239
+ "priceMinor": 4000
1240
+ }
1241
+ ]
1242
+ },
1243
+ "expect": {
1244
+ "priceMinor": 4000,
1245
+ "rule": "off-peak"
1246
+ }
1247
+ },
1248
+ {
1249
+ "name": "no rule applies",
1250
+ "input": {
1251
+ "timezone": "Europe/London",
1252
+ "startUtc": "2026-10-14T13:00:00Z",
1253
+ "basePriceMinor": 5000,
1254
+ "rules": [
1255
+ {
1256
+ "name": "off-peak",
1257
+ "weekdays": [
1258
+ 0,
1259
+ 1,
1260
+ 2,
1261
+ 3,
1262
+ 4
1263
+ ],
1264
+ "from": "09:00",
1265
+ "to": "12:00",
1266
+ "priceMinor": 4000
1267
+ }
1268
+ ]
1269
+ },
1270
+ "expect": {
1271
+ "priceMinor": 5000,
1272
+ "rule": null
1273
+ }
1274
+ }
1275
+ ]
1276
+ },
1277
+ {
1278
+ "id": "permission_check",
1279
+ "why": "601910: 'own bookings only' was tied to a role name and the PIN switch did not reach pages, menus or clock-in",
1280
+ "vectors": [
1281
+ {
1282
+ "name": "therapist sees only own bookings",
1283
+ "input": {
1284
+ "role": "therapist",
1285
+ "permission": "bookings.edit",
1286
+ "bookingStaffId": "s2",
1287
+ "actorStaffId": "s1"
1288
+ },
1289
+ "expect": {
1290
+ "allowed": false,
1291
+ "reason": "own_bookings_only"
1292
+ }
1293
+ },
1294
+ {
1295
+ "name": "reception edits anyone's booking",
1296
+ "input": {
1297
+ "role": "reception",
1298
+ "permission": "bookings.edit",
1299
+ "bookingStaffId": "s2",
1300
+ "actorStaffId": "s1"
1301
+ },
1302
+ "expect": {
1303
+ "allowed": true
1304
+ }
1305
+ },
1306
+ {
1307
+ "name": "owner grants a therapist all bookings",
1308
+ "input": {
1309
+ "role": "therapist",
1310
+ "permission": "bookings.edit",
1311
+ "bookingStaffId": "s2",
1312
+ "actorStaffId": "s1",
1313
+ "overrides": {
1314
+ "therapist": {
1315
+ "grant": [
1316
+ "bookings.view_all"
1317
+ ]
1318
+ }
1319
+ }
1320
+ },
1321
+ "expect": {
1322
+ "allowed": true
1323
+ }
1324
+ },
1325
+ {
1326
+ "name": "team management is never delegable",
1327
+ "input": {
1328
+ "role": "manager",
1329
+ "permission": "team.manage",
1330
+ "overrides": {
1331
+ "manager": {
1332
+ "grant": [
1333
+ "team.manage"
1334
+ ]
1335
+ }
1336
+ }
1337
+ },
1338
+ "expect": {
1339
+ "allowed": false,
1340
+ "reason": "owner_only"
1341
+ }
1342
+ },
1343
+ {
1344
+ "name": "PIN switch lowers the owner to therapist for refunds",
1345
+ "input": {
1346
+ "role": "owner",
1347
+ "pinRole": "therapist",
1348
+ "permission": "checkout.refund"
1349
+ },
1350
+ "expect": {
1351
+ "allowed": false,
1352
+ "effectiveRole": "therapist"
1353
+ }
1354
+ },
1355
+ {
1356
+ "name": "PIN switch applies to clock-in too",
1357
+ "input": {
1358
+ "role": "owner",
1359
+ "pinRole": "therapist",
1360
+ "permission": "timeclock.self"
1361
+ },
1362
+ "expect": {
1363
+ "allowed": true,
1364
+ "effectiveRole": "therapist"
1365
+ }
1366
+ },
1367
+ {
1368
+ "name": "PIN switch can never raise a role",
1369
+ "input": {
1370
+ "role": "therapist",
1371
+ "pinRole": "manager",
1372
+ "permission": "reports.view"
1373
+ },
1374
+ "expect": {
1375
+ "allowed": false,
1376
+ "reason": "pin_cannot_raise_role"
1377
+ }
1378
+ },
1379
+ {
1380
+ "name": "owner revokes refunds from managers",
1381
+ "input": {
1382
+ "role": "manager",
1383
+ "permission": "checkout.refund",
1384
+ "overrides": {
1385
+ "manager": {
1386
+ "revoke": [
1387
+ "checkout.refund"
1388
+ ]
1389
+ }
1390
+ }
1391
+ },
1392
+ "expect": {
1393
+ "allowed": false,
1394
+ "reason": "missing_permission"
1395
+ }
1396
+ }
1397
+ ]
1398
+ },
1399
+ {
1400
+ "id": "online_booking_gate",
1401
+ "why": "66c948: missing patch tests and blocked clients did not stop online booking",
1402
+ "vectors": [
1403
+ {
1404
+ "name": "blocked client gets a neutral message",
1405
+ "input": {
1406
+ "client": {
1407
+ "blocked": true
1408
+ },
1409
+ "service": {},
1410
+ "startAt": "2026-10-20T09:00:00Z"
1411
+ },
1412
+ "expect": {
1413
+ "allowed": false,
1414
+ "reason": "contact_business"
1415
+ }
1416
+ },
1417
+ {
1418
+ "name": "patch test required",
1419
+ "input": {
1420
+ "client": {},
1421
+ "service": {
1422
+ "patchTestCategory": "lash-tint"
1423
+ },
1424
+ "startAt": "2026-10-20T09:00:00Z"
1425
+ },
1426
+ "expect": {
1427
+ "allowed": false,
1428
+ "reason": "patch_test_required"
1429
+ }
1430
+ },
1431
+ {
1432
+ "name": "patch test expired",
1433
+ "input": {
1434
+ "client": {
1435
+ "patchTests": {
1436
+ "lash-tint": "2026-01-01T09:00:00Z"
1437
+ }
1438
+ },
1439
+ "service": {
1440
+ "patchTestCategory": "lash-tint",
1441
+ "patchTestValidDays": 180
1442
+ },
1443
+ "startAt": "2026-10-20T09:00:00Z"
1444
+ },
1445
+ "expect": {
1446
+ "allowed": false,
1447
+ "reason": "patch_test_expired"
1448
+ }
1449
+ },
1450
+ {
1451
+ "name": "patch test less than 48 h before",
1452
+ "input": {
1453
+ "client": {
1454
+ "patchTests": {
1455
+ "lash-tint": "2026-10-19T10:00:00Z"
1456
+ }
1457
+ },
1458
+ "service": {
1459
+ "patchTestCategory": "lash-tint"
1460
+ },
1461
+ "startAt": "2026-10-20T09:00:00Z"
1462
+ },
1463
+ "expect": {
1464
+ "allowed": false,
1465
+ "reason": "patch_test_too_recent"
1466
+ }
1467
+ },
1468
+ {
1469
+ "name": "valid patch test",
1470
+ "input": {
1471
+ "client": {
1472
+ "patchTests": {
1473
+ "lash-tint": "2026-10-01T10:00:00Z"
1474
+ }
1475
+ },
1476
+ "service": {
1477
+ "patchTestCategory": "lash-tint"
1478
+ },
1479
+ "startAt": "2026-10-20T09:00:00Z"
1480
+ },
1481
+ "expect": {
1482
+ "allowed": true
1483
+ }
1484
+ },
1485
+ {
1486
+ "name": "no patch test needed",
1487
+ "input": {
1488
+ "client": {},
1489
+ "service": {
1490
+ "id": "massage"
1491
+ },
1492
+ "startAt": "2026-10-20T09:00:00Z"
1493
+ },
1494
+ "expect": {
1495
+ "allowed": true
1496
+ }
1497
+ }
1498
+ ]
1499
+ },
1500
+ {
1501
+ "id": "merge_clients",
1502
+ "why": "b54ba7: the first client merge lost loyalty points",
1503
+ "vectors": [
1504
+ {
1505
+ "name": "points, tier, history and memberships survive a merge",
1506
+ "input": {
1507
+ "primary": {
1508
+ "points": 120,
1509
+ "lifetimePoints": 300,
1510
+ "history": [
1511
+ 1,
1512
+ 2,
1513
+ 3
1514
+ ],
1515
+ "memberships": [
1516
+ "m1"
1517
+ ]
1518
+ },
1519
+ "duplicate": {
1520
+ "points": 80,
1521
+ "lifetimePoints": 250,
1522
+ "history": [
1523
+ 4,
1524
+ 5
1525
+ ],
1526
+ "memberships": [
1527
+ "m2"
1528
+ ]
1529
+ },
1530
+ "tiers": [
1531
+ {
1532
+ "name": "bronze",
1533
+ "minLifetimePoints": 0
1534
+ },
1535
+ {
1536
+ "name": "silver",
1537
+ "minLifetimePoints": 500
1538
+ },
1539
+ {
1540
+ "name": "gold",
1541
+ "minLifetimePoints": 1000
1542
+ }
1543
+ ]
1544
+ },
1545
+ "expect": {
1546
+ "points": 200,
1547
+ "lifetimePoints": 550,
1548
+ "tier": "silver",
1549
+ "historyEntries": 5,
1550
+ "memberships": [
1551
+ "m1",
1552
+ "m2"
1553
+ ]
1554
+ }
1555
+ }
1556
+ ]
1557
+ },
1558
+ {
1559
+ "id": "membership_credits",
1560
+ "why": "b54ba7: memberships with pause",
1561
+ "vectors": [
1562
+ {
1563
+ "name": "a paused month neither bills nor adds credits",
1564
+ "input": {
1565
+ "creditsPerMonth": 2,
1566
+ "priceMinor": 3000,
1567
+ "months": [
1568
+ {
1569
+ "state": "active"
1570
+ },
1571
+ {
1572
+ "state": "paused"
1573
+ },
1574
+ {
1575
+ "state": "active"
1576
+ }
1577
+ ]
1578
+ },
1579
+ "expect": {
1580
+ "billedMonths": 2,
1581
+ "creditsAdded": 4,
1582
+ "amountBilledMinor": 6000
1583
+ }
1584
+ }
1585
+ ]
1586
+ },
1587
+ {
1588
+ "id": "commission",
1589
+ "why": "de5f23: team pay",
1590
+ "vectors": [
1591
+ {
1592
+ "name": "service and product rates per person, on net sales",
1593
+ "input": {
1594
+ "lines": [
1595
+ {
1596
+ "kind": "service",
1597
+ "netMinor": 10000,
1598
+ "staffId": "a"
1599
+ },
1600
+ {
1601
+ "kind": "product",
1602
+ "netMinor": 2000,
1603
+ "staffId": "a"
1604
+ },
1605
+ {
1606
+ "kind": "service",
1607
+ "netMinor": 5000,
1608
+ "staffId": "b"
1609
+ }
1610
+ ],
1611
+ "rates": {
1612
+ "a": {
1613
+ "service": 0.4,
1614
+ "product": 0.1
1615
+ },
1616
+ "b": {
1617
+ "service": 0.35
1618
+ }
1619
+ }
1620
+ },
1621
+ "expect": {
1622
+ "commissionMinor": {
1623
+ "a": 4200,
1624
+ "b": 1750
1625
+ }
1626
+ }
1627
+ }
1628
+ ]
1629
+ },
1630
+ {
1631
+ "id": "hourly_pay",
1632
+ "why": "de5f23: clock-in and hourly pay",
1633
+ "vectors": [
1634
+ {
1635
+ "name": "two shifts with an unpaid break",
1636
+ "input": {
1637
+ "rateMinor": 1250,
1638
+ "shifts": [
1639
+ {
1640
+ "in": "2026-10-13T09:00:00Z",
1641
+ "out": "2026-10-13T17:30:00Z",
1642
+ "breakMinutes": 30
1643
+ },
1644
+ {
1645
+ "in": "2026-10-14T09:00:00Z",
1646
+ "out": "2026-10-14T13:20:00Z"
1647
+ }
1648
+ ]
1649
+ },
1650
+ "expect": {
1651
+ "minutes": 740,
1652
+ "payMinor": 15417
1653
+ }
1654
+ }
1655
+ ]
1656
+ },
1657
+ {
1658
+ "id": "link_booking_to_account",
1659
+ "why": "1bc306: token bookings join a signed-in account by verified e-mail",
1660
+ "vectors": [
1661
+ {
1662
+ "name": "verified, same e-mail ignoring case and spaces",
1663
+ "input": {
1664
+ "bookingEmail": " Amy@Example.com ",
1665
+ "accountEmail": "amy@example.com",
1666
+ "accountEmailVerified": true
1667
+ },
1668
+ "expect": {
1669
+ "link": true
1670
+ }
1671
+ },
1672
+ {
1673
+ "name": "unverified e-mail never links",
1674
+ "input": {
1675
+ "bookingEmail": "amy@example.com",
1676
+ "accountEmail": "amy@example.com",
1677
+ "accountEmailVerified": false
1678
+ },
1679
+ "expect": {
1680
+ "link": false,
1681
+ "reason": "email_not_verified"
1682
+ }
1683
+ },
1684
+ {
1685
+ "name": "different e-mail",
1686
+ "input": {
1687
+ "bookingEmail": "amy@example.com",
1688
+ "accountEmail": "amy2@example.com",
1689
+ "accountEmailVerified": true
1690
+ },
1691
+ "expect": {
1692
+ "link": false,
1693
+ "reason": "different_email"
1694
+ }
1695
+ }
1696
+ ]
1697
+ }
1698
+ ]
1699
+ }