@topy-ai/maggie 0.7.46 → 0.7.48
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.
- package/README-zh-TW.md +12 -4
- package/README.md +55 -11
- package/bin/maggie.js +26 -4
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/memory-hook.md +18 -5
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-deployment/SKILL.md +59 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +41 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +295 -0
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +107 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/memory-hook.md +18 -5
- package/references/provider-adapter-contract.md +33 -0
|
@@ -7,6 +7,8 @@ import argparse
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import struct
|
|
11
|
+
import zlib
|
|
10
12
|
import secrets
|
|
11
13
|
import shlex
|
|
12
14
|
import shutil
|
|
@@ -17,6 +19,7 @@ from pathlib import Path
|
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
22
|
+
STRIPE_SECRET_ENV_KEYS = ("STRIPE_SECRET_KEY", "ST_SECRECT_KEY")
|
|
20
23
|
# Source checkouts keep contracts and the delivery board at repository root;
|
|
21
24
|
# npm packages keep the same artifacts under bundled-contracts. Resolve both
|
|
22
25
|
# layouts so the published CLI uses the exact same Booking gates as the repo.
|
|
@@ -26,7 +29,9 @@ DEFAULT_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-host-adapter-v1.json
|
|
|
26
29
|
DEFAULT_ACCESS_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-access-v1.json"
|
|
27
30
|
DEFAULT_EMAIL_TEMPLATES_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-email-templates-v1.json"
|
|
28
31
|
DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evidence-v1.json"
|
|
32
|
+
DEFAULT_BROWSER_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-browser-evidence-v2.json"
|
|
29
33
|
DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
|
|
34
|
+
DEFAULT_VOUCHER_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-voucher-lifecycle-v1.json"
|
|
30
35
|
EXECUTION_BOARD = (
|
|
31
36
|
CONTRACTS_ROOT / "maggiedash" / "execution-board.json"
|
|
32
37
|
if PACKAGED_CONTRACTS_ROOT.is_dir()
|
|
@@ -149,7 +154,7 @@ def stripe_contract_checks(contract: dict, max_age_days: int = 120) -> list[str]
|
|
|
149
154
|
errors.extend(f"missing API matrix entry: {item}" for item in sorted(required_apis - api_ids))
|
|
150
155
|
events = contract.get("x-webhook-events") or contract.get("webhookEvents") or []
|
|
151
156
|
event_ids = {item.get("eventType") for item in events if isinstance(item, dict)}
|
|
152
|
-
required_events = {"checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "payment_intent.processing", "payment_intent.payment_failed", "payment_intent.succeeded", "payment_intent.requires_action", "payment_intent.canceled", "refund.created", "refund.updated", "refund.failed"}
|
|
157
|
+
required_events = {"checkout.session.completed", "checkout.session.expired", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "payment_intent.processing", "payment_intent.payment_failed", "payment_intent.succeeded", "payment_intent.requires_action", "payment_intent.canceled", "refund.created", "refund.updated", "refund.failed"}
|
|
153
158
|
errors.extend(f"missing webhook event: {item}" for item in sorted(required_events - event_ids))
|
|
154
159
|
native = contract.get("x-native-capabilities") or contract.get("nativeCapabilities") or {}
|
|
155
160
|
if native.get("giftCards") != "not-native": errors.append("giftCards must remain not-native")
|
|
@@ -284,12 +289,16 @@ def email_templates_contract_checks(contract: dict) -> list[str]:
|
|
|
284
289
|
if auth.get("passkeys") is not False: errors.append("passkeys must be disabled")
|
|
285
290
|
if roles != ["owner", "admin", "manager"]: errors.append("roles must be owner, admin, manager")
|
|
286
291
|
events = contract.get("events")
|
|
287
|
-
if not isinstance(events, list) or len(events) !=
|
|
292
|
+
if not isinstance(events, list) or len(events) != 10: errors.append("exactly ten Booking email events are required")
|
|
288
293
|
event_ids = [item.get("id") for item in events if isinstance(item, dict)] if isinstance(events, list) else []
|
|
289
|
-
expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
|
|
294
|
+
expected_events = ["booking.confirmed", "booking.rescheduled", "booking.cancelled", "booking.reminder", "payment.action_required", "payment.refunded", "payment.no_show_fee", "package.purchase.confirmed", "package.purchase.refunded", "booking.waitlist.available"]
|
|
290
295
|
if event_ids != expected_events: errors.append("email event IDs are incomplete or out of order")
|
|
291
296
|
if len(event_ids) != len(set(event_ids)): errors.append("email event IDs must be unique")
|
|
292
|
-
allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName"}
|
|
297
|
+
allowed = {"customerName", "serviceName", "when", "locationName", "bookingId", "manageUrl", "bookingUrl", "reason", "actionUrl", "packageName", "amount", "expiresAt", "siteName", "arrivalNote"}
|
|
298
|
+
declared_variables = contract.get("variables")
|
|
299
|
+
event_variables = {variable for event in events if isinstance(event, dict) for variable in event.get("variables", []) if isinstance(variable, str)} if isinstance(events, list) else set()
|
|
300
|
+
if declared_variables is not None and (not isinstance(declared_variables, list) or set(declared_variables) != event_variables or len(declared_variables) != len(set(declared_variables))):
|
|
301
|
+
errors.append("email template global variables must exactly match the event allowlists")
|
|
293
302
|
for event in events if isinstance(events, list) else []:
|
|
294
303
|
variables = event.get("variables") if isinstance(event, dict) else None
|
|
295
304
|
if not isinstance(variables, list) or not variables: errors.append(f"{event.get('id', 'unknown')}: variables must be a non-empty list")
|
|
@@ -384,6 +393,23 @@ def command_email_templates_contract(args: argparse.Namespace) -> int:
|
|
|
384
393
|
return 0 if not errors else 1
|
|
385
394
|
|
|
386
395
|
|
|
396
|
+
def command_voucher_contract(args: argparse.Namespace) -> int:
|
|
397
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
398
|
+
from maggie_booking_vouchers import voucher_contract_checks, voucher_ledger_checks
|
|
399
|
+
path = Path(args.contract).expanduser().resolve()
|
|
400
|
+
contract = load_json(path)
|
|
401
|
+
errors = voucher_contract_checks(contract)
|
|
402
|
+
result = {"status": "passed", "contract": str(path), "schemaVersion": contract.get("schemaVersion"), "runtimeOwner": (contract.get("runtime") or {}).get("owner"), "errors": errors}
|
|
403
|
+
if args.ledger:
|
|
404
|
+
ledger_path = Path(args.ledger).expanduser().resolve()
|
|
405
|
+
vouchers, ledger_errors = voucher_ledger_checks(load_json(ledger_path))
|
|
406
|
+
result.update({"ledger": str(ledger_path), "vouchers": vouchers})
|
|
407
|
+
errors.extend(ledger_errors)
|
|
408
|
+
result["status"] = "passed" if not errors else "failed"
|
|
409
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
410
|
+
return 0 if not errors else 1
|
|
411
|
+
|
|
412
|
+
|
|
387
413
|
def project_path(args: argparse.Namespace) -> Path:
|
|
388
414
|
return Path(args.project).expanduser().resolve()
|
|
389
415
|
|
|
@@ -625,10 +651,21 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
625
651
|
has_sender = has("EMAIL_FROM") or has("RESEND_FROM_EMAIL")
|
|
626
652
|
token_present = has("BOOKING_TOKEN_SECRET")
|
|
627
653
|
token_valid = token_present and len(values.get("BOOKING_TOKEN_SECRET", "")) >= 32
|
|
628
|
-
stripe_secret = has(
|
|
654
|
+
stripe_secret = any(has(key) for key in STRIPE_SECRET_ENV_KEYS)
|
|
655
|
+
stripe_legacy_alias = has("ST_SECRECT_KEY") and not has("STRIPE_SECRET_KEY")
|
|
629
656
|
stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
|
|
630
657
|
stripe_complete = stripe_secret and stripe_webhook
|
|
631
658
|
stripe_partial = stripe_secret != stripe_webhook
|
|
659
|
+
stripe_key_help = "STRIPE_SECRET_KEY (or existing ORA alias ST_SECRECT_KEY)"
|
|
660
|
+
stripe_detail = (
|
|
661
|
+
"Recognizes ORA's existing ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
|
|
662
|
+
if stripe_legacy_alias and stripe_complete
|
|
663
|
+
else f"Both {stripe_key_help} and STRIPE_WEBHOOK_SECRET are required for online payments."
|
|
664
|
+
if stripe_partial
|
|
665
|
+
else "Required only for deposit/full online payment flows; webhook URL is /api/maggie/booking/webhooks/stripe."
|
|
666
|
+
if stripe_secret or stripe_webhook
|
|
667
|
+
else "Optional at install: an owner or manager can connect Stripe from Booking Overview, or use the deployment secret store."
|
|
668
|
+
)
|
|
632
669
|
workspace = (root / "_maggie" / "booking" / "booking.tsx").is_file()
|
|
633
670
|
middleware = root / "src" / "middleware.ts"
|
|
634
671
|
middleware_text = middleware.read_text(encoding="utf-8") if middleware.is_file() else ""
|
|
@@ -641,7 +678,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
641
678
|
setup_check("site", "Public site URL", ["PUBLIC_SITE_URL"], has("PUBLIC_SITE_URL"), detail="Used for login links, booking links, and email URLs."),
|
|
642
679
|
setup_check("booking-token", "Public booking token secret", ["BOOKING_TOKEN_SECRET"], token_valid, detail="Must be at least 32 characters; used to sign public manage-booking links." if token_valid or not token_present else "The configured secret is too short; use at least 32 characters.", status_override="passed" if token_valid else "invalid" if token_present else None),
|
|
643
680
|
setup_check("resend", "Resend email delivery", ["RESEND_API_KEY", "EMAIL_FROM|RESEND_FROM_EMAIL"], has("RESEND_API_KEY") and has_sender, detail="Required for password setup/reset, booking notifications, and reminders."),
|
|
644
|
-
setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail=
|
|
681
|
+
setup_check("stripe", "Stripe online payments", ["STRIPE_SECRET_KEY|ST_SECRECT_KEY", "STRIPE_WEBHOOK_SECRET"], stripe_complete, required=False, detail=stripe_detail, status_override="passed" if stripe_complete else "degraded" if stripe_partial else "optional"),
|
|
645
682
|
{"id": "schema", "label": "Booking database schema", "status": "not-verified", "required": True, "detail": "The CLI does not connect to PostgreSQL. Apply the schema after backup, then verify with the host runtime checks."},
|
|
646
683
|
{"id": "owner", "label": "First owner account", "status": "not-verified", "required": True, "detail": "Open /_maggie/register once the schema is applied. Registration closes after the first owner."},
|
|
647
684
|
{"id": "worker", "label": "Resend worker schedule", "status": "not-verified", "required": True, "detail": "Run the installed worker from a single-flight scheduler; setup does not create a cron or systemd job."},
|
|
@@ -656,7 +693,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
656
693
|
if "booking-token" in missing_config:
|
|
657
694
|
next_steps.append("Regenerate BOOKING_TOKEN_SECRET with at least 32 characters before enabling public customer manage links.")
|
|
658
695
|
if "stripe" in degraded_config:
|
|
659
|
-
next_steps.append("Configure both
|
|
696
|
+
next_steps.append(f"Configure both {stripe_key_help} and STRIPE_WEBHOOK_SECRET before accepting online payments.")
|
|
660
697
|
if missing_dependencies:
|
|
661
698
|
next_steps.append("For a new host, install missing packages and apply the schema together: maggie booking install --project . --confirm --ready")
|
|
662
699
|
if not missing_dependencies and any(item["id"] == "schema" and item["status"] == "not-verified" for item in checks):
|
|
@@ -676,7 +713,7 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
|
|
|
676
713
|
"status": status,
|
|
677
714
|
"project": str(root),
|
|
678
715
|
"checks": checks,
|
|
679
|
-
"configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"}),
|
|
716
|
+
"configuredKeys": sorted(key for key in sources if key in {"DATABASE_URL", "PUBLIC_SITE_URL", "BOOKING_TOKEN_SECRET", "RESEND_API_KEY", "EMAIL_FROM", "RESEND_FROM_EMAIL", "STRIPE_SECRET_KEY", "ST_SECRECT_KEY", "STRIPE_WEBHOOK_SECRET"}),
|
|
680
717
|
"verificationPending": verification_pending,
|
|
681
718
|
"paymentConfiguration": "online-payments-pending-verification" if stripe_complete else "incomplete-deployment-stripe" if stripe_partial else "dashboard-or-host-secret-pending",
|
|
682
719
|
"next": next_steps,
|
|
@@ -946,7 +983,7 @@ def command_inspect(args: argparse.Namespace) -> int:
|
|
|
946
983
|
"stripeContract": stripe_contract.is_file(),
|
|
947
984
|
"schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file(),
|
|
948
985
|
"runtimeContract": (root / "maggiedash" / "contracts" / "booking-runtime-v1.json").is_file(),
|
|
949
|
-
"browserEvidenceContract": (root / "maggiedash" / "contracts" / "booking-browser-evidence-
|
|
986
|
+
"browserEvidenceContract": (root / "maggiedash" / "contracts" / "booking-browser-evidence-v2.json").is_file(),
|
|
950
987
|
"runtimePlanValidator": (root / "maggiedash" / "scripts" / "validate-booking-runtime-plan.mjs").is_file(),
|
|
951
988
|
"customerFlowValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-flow.mjs").is_file(),
|
|
952
989
|
"customerUIValidator": (root / "maggiedash" / "scripts" / "validate-booking-customer-ui.mjs").is_file(),
|
|
@@ -1014,36 +1051,147 @@ def runtime_result(evidence: dict, contract: dict) -> dict:
|
|
|
1014
1051
|
return {"status": "passed" if not errors and bool(observations) else "failed", "checked": len(observations), "required": len(required), "missing": missing, "errors": errors}
|
|
1015
1052
|
|
|
1016
1053
|
|
|
1017
|
-
def browser_result(evidence: dict, artifact_root: Path) -> dict:
|
|
1054
|
+
def browser_result(evidence: dict, artifact_root: Path, contract: dict | None = None) -> dict:
|
|
1018
1055
|
"""Validate sanitized browser evidence without importing a browser runtime."""
|
|
1019
1056
|
errors: list[str] = []
|
|
1020
|
-
|
|
1057
|
+
contract = contract or load_json(DEFAULT_BROWSER_EVIDENCE_CONTRACT)
|
|
1058
|
+
if contract.get("properties", {}).get("schemaVersion", {}).get("const") != "maggiedash-booking-browser-evidence.v2": errors.append("browser evidence contract is unsupported")
|
|
1059
|
+
if evidence.get("schemaVersion") != "maggiedash-booking-browser-evidence.v2": errors.append("browser schemaVersion is unsupported")
|
|
1021
1060
|
if not str(evidence.get("baseUrl", "")).startswith("https://"): errors.append("browser baseUrl must use HTTPS")
|
|
1022
1061
|
generated_at = evidence.get("generatedAt")
|
|
1023
1062
|
try:
|
|
1024
1063
|
datetime.fromisoformat(str(generated_at).replace("Z", "+00:00"))
|
|
1025
1064
|
except (TypeError, ValueError):
|
|
1026
1065
|
errors.append("browser generatedAt must be an ISO timestamp")
|
|
1027
|
-
|
|
1066
|
+
required_viewports = contract.get("x-required-viewports") if isinstance(contract.get("x-required-viewports"), list) else []
|
|
1067
|
+
viewport_map = {(item.get("name"), item.get("width"), item.get("height")) for item in required_viewports if isinstance(item, dict)}
|
|
1028
1068
|
actual_viewports = evidence.get("viewports") if isinstance(evidence.get("viewports"), list) else []
|
|
1029
1069
|
observed_viewports = {(item.get("name"), item.get("width"), item.get("height")) for item in actual_viewports if isinstance(item, dict)}
|
|
1030
1070
|
for viewport in sorted(viewport_map - observed_viewports):
|
|
1031
1071
|
errors.append(f"missing viewport {viewport[0]} {viewport[1]}x{viewport[2]}")
|
|
1032
|
-
routes =
|
|
1072
|
+
routes = contract.get("x-required-routes") if isinstance(contract.get("x-required-routes"), list) else []
|
|
1073
|
+
expected_headings = contract.get("x-required-headings") if isinstance(contract.get("x-required-headings"), dict) else {}
|
|
1074
|
+
if not routes or len(set(routes)) != len(routes): errors.append("browser evidence contract must define unique required routes")
|
|
1075
|
+
if len(expected_headings) != len(routes) or any(not isinstance(expected_headings.get(route), str) or not expected_headings[route].strip() for route in routes): errors.append("browser evidence contract must define one heading for every required route")
|
|
1033
1076
|
screens = evidence.get("screens") if isinstance(evidence.get("screens"), list) else []
|
|
1077
|
+
expected_count = len(routes) * len(viewport_map)
|
|
1078
|
+
if len(screens) != expected_count: errors.append(f"browser screen count must be exactly {expected_count}")
|
|
1079
|
+
expected_pairs = {(route, viewport[0]) for route in routes for viewport in viewport_map}
|
|
1080
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
1081
|
+
for item in screens:
|
|
1082
|
+
if not isinstance(item, dict):
|
|
1083
|
+
errors.append("browser screen must be an object")
|
|
1084
|
+
continue
|
|
1085
|
+
pair = (item.get("route"), item.get("viewport"))
|
|
1086
|
+
if pair not in expected_pairs: errors.append(f"unexpected browser screen {pair[1]}:{pair[0]}")
|
|
1087
|
+
if pair in seen_pairs: errors.append(f"duplicate browser screen {pair[1]}:{pair[0]}")
|
|
1088
|
+
seen_pairs.add(pair)
|
|
1034
1089
|
for route in routes:
|
|
1035
|
-
for viewport in (
|
|
1090
|
+
for viewport in sorted({item[0] for item in viewport_map}):
|
|
1036
1091
|
item = next((row for row in screens if isinstance(row, dict) and row.get("route") == route and row.get("viewport") == viewport), None)
|
|
1037
1092
|
if not item: errors.append(f"missing browser screen {viewport}:{route}"); continue
|
|
1038
1093
|
if not isinstance(item.get("httpStatus"), int) or not 200 <= item["httpStatus"] < 400: errors.append(f"browser screen is not successful {viewport}:{route}")
|
|
1094
|
+
final_path = item.get("finalPath")
|
|
1095
|
+
normalized_final_path = final_path.rstrip("/") if isinstance(final_path, str) and final_path != "/" else final_path
|
|
1096
|
+
normalized_route = route.rstrip("/") if route != "/" else route
|
|
1097
|
+
if not isinstance(final_path, str) or not final_path.startswith("/") or final_path.startswith("//") or "?" in final_path or "#" in final_path or normalized_final_path != normalized_route: errors.append(f"browser screen final path does not match route {viewport}:{route}")
|
|
1098
|
+
if not isinstance(item.get("heading"), str) or item["heading"].strip() != expected_headings.get(route): errors.append(f"browser screen heading does not match route {viewport}:{route}")
|
|
1039
1099
|
if item.get("ready") is not True: errors.append(f"browser screen is not ready {viewport}:{route}")
|
|
1040
1100
|
screenshot = item.get("screenshot")
|
|
1041
1101
|
if not isinstance(screenshot, str) or Path(screenshot).is_absolute() or ".." in Path(screenshot).parts: errors.append(f"unsafe browser screenshot path {viewport}:{route}")
|
|
1042
|
-
|
|
1102
|
+
else:
|
|
1103
|
+
path = artifact_root / screenshot
|
|
1104
|
+
if not path.is_file():
|
|
1105
|
+
errors.append(f"missing browser screenshot {screenshot}")
|
|
1106
|
+
else:
|
|
1107
|
+
expected = next((width, height) for name, width, height in viewport_map if name == viewport)
|
|
1108
|
+
try:
|
|
1109
|
+
image_format, width, height = browser_image_dimensions(path)
|
|
1110
|
+
suffix = path.suffix.lower()
|
|
1111
|
+
extensions = {"png": {".png"}, "jpeg": {".jpg", ".jpeg"}}
|
|
1112
|
+
if suffix not in extensions.get(image_format, set()):
|
|
1113
|
+
errors.append(f"browser screenshot format does not match extension {viewport}:{route}")
|
|
1114
|
+
if width != expected[0] or height < expected[1]:
|
|
1115
|
+
errors.append(f"browser screenshot dimensions do not match viewport {viewport}:{route}")
|
|
1116
|
+
except (OSError, ValueError, struct.error):
|
|
1117
|
+
errors.append(f"browser screenshot is not a supported image {viewport}:{route}")
|
|
1043
1118
|
if evidence.get("consoleErrors") != []: errors.append("browser consoleErrors is not empty")
|
|
1044
1119
|
if evidence.get("failedRequests") != []: errors.append("browser failedRequests is not empty")
|
|
1045
1120
|
if evidence.get("layoutErrors") != []: errors.append("browser layoutErrors is not empty")
|
|
1046
|
-
return {"status": "passed" if not errors else "failed", "screens": len(screens), "required":
|
|
1121
|
+
return {"status": "passed" if not errors else "failed", "screens": len(screens), "required": expected_count, "errors": errors}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def browser_image_dimensions(path: Path) -> tuple[str, int, int]:
|
|
1125
|
+
"""Read PNG/JPEG dimensions while bounding the evidence artifact size."""
|
|
1126
|
+
if path.stat().st_size > 25 * 1024 * 1024:
|
|
1127
|
+
raise ValueError("screenshot exceeds 25 MiB")
|
|
1128
|
+
data = path.read_bytes()
|
|
1129
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
1130
|
+
offset = 8
|
|
1131
|
+
width = height = 0
|
|
1132
|
+
found_header = found_data = found_end = False
|
|
1133
|
+
while offset + 12 <= len(data):
|
|
1134
|
+
chunk_length = struct.unpack(">I", data[offset:offset + 4])[0]
|
|
1135
|
+
chunk_end = offset + 12 + chunk_length
|
|
1136
|
+
if chunk_end > len(data):
|
|
1137
|
+
break
|
|
1138
|
+
kind = data[offset + 4:offset + 8]
|
|
1139
|
+
payload = data[offset + 8:offset + 8 + chunk_length]
|
|
1140
|
+
expected_crc = struct.unpack(">I", data[offset + 8 + chunk_length:chunk_end])[0]
|
|
1141
|
+
if zlib.crc32(kind + payload) & 0xFFFFFFFF != expected_crc:
|
|
1142
|
+
raise ValueError("invalid PNG chunk checksum")
|
|
1143
|
+
if not found_header:
|
|
1144
|
+
if kind != b"IHDR" or chunk_length != 13:
|
|
1145
|
+
raise ValueError("invalid PNG header")
|
|
1146
|
+
width, height = struct.unpack(">II", payload[:8])
|
|
1147
|
+
found_header = True
|
|
1148
|
+
elif kind == b"IHDR":
|
|
1149
|
+
raise ValueError("duplicate PNG header")
|
|
1150
|
+
if kind == b"IDAT" and chunk_length:
|
|
1151
|
+
found_data = True
|
|
1152
|
+
if kind == b"IEND":
|
|
1153
|
+
if chunk_length != 0 or chunk_end != len(data):
|
|
1154
|
+
raise ValueError("invalid PNG end chunk")
|
|
1155
|
+
found_end = True
|
|
1156
|
+
break
|
|
1157
|
+
offset = chunk_end
|
|
1158
|
+
if not found_header or not found_data or not found_end or not width or not height:
|
|
1159
|
+
raise ValueError("incomplete PNG image")
|
|
1160
|
+
return "png", width, height
|
|
1161
|
+
if not data.startswith(b"\xff\xd8"):
|
|
1162
|
+
raise ValueError("unsupported image format")
|
|
1163
|
+
offset = 2
|
|
1164
|
+
frame_markers = {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
|
|
1165
|
+
dimensions = None
|
|
1166
|
+
while offset < len(data):
|
|
1167
|
+
while offset < len(data) and data[offset] != 0xFF:
|
|
1168
|
+
offset += 1
|
|
1169
|
+
while offset < len(data) and data[offset] == 0xFF:
|
|
1170
|
+
offset += 1
|
|
1171
|
+
if offset >= len(data):
|
|
1172
|
+
break
|
|
1173
|
+
marker = data[offset]
|
|
1174
|
+
offset += 1
|
|
1175
|
+
if marker in {0xD8, 0xD9} or 0xD0 <= marker <= 0xD7:
|
|
1176
|
+
continue
|
|
1177
|
+
if offset + 2 > len(data):
|
|
1178
|
+
break
|
|
1179
|
+
segment_length = int.from_bytes(data[offset:offset + 2], "big")
|
|
1180
|
+
if segment_length < 2 or offset + segment_length > len(data):
|
|
1181
|
+
break
|
|
1182
|
+
if marker in frame_markers:
|
|
1183
|
+
if segment_length < 7:
|
|
1184
|
+
break
|
|
1185
|
+
height, width = struct.unpack(">HH", data[offset + 3:offset + 7])
|
|
1186
|
+
if not width or not height:
|
|
1187
|
+
break
|
|
1188
|
+
dimensions = (width, height)
|
|
1189
|
+
if marker == 0xDA:
|
|
1190
|
+
if dimensions and data.endswith(b"\xff\xd9"):
|
|
1191
|
+
return "jpeg", *dimensions
|
|
1192
|
+
break
|
|
1193
|
+
offset += segment_length
|
|
1194
|
+
raise ValueError("JPEG dimensions were not found")
|
|
1047
1195
|
|
|
1048
1196
|
|
|
1049
1197
|
def command_runtime_validate(args: argparse.Namespace) -> int:
|
|
@@ -1055,14 +1203,29 @@ def command_runtime_validate(args: argparse.Namespace) -> int:
|
|
|
1055
1203
|
|
|
1056
1204
|
|
|
1057
1205
|
def ops_evidence_result(evidence: dict, contract: dict, require_host: bool = False) -> dict:
|
|
1206
|
+
if not isinstance(evidence, dict):
|
|
1207
|
+
return {"status": "failed", "checks": [], "requiredAlertCodes": 0, "errors": ["ops evidence must be a JSON object"]}
|
|
1208
|
+
if not isinstance(contract, dict):
|
|
1209
|
+
return {"status": "failed", "checks": [], "requiredAlertCodes": 0, "errors": ["ops evidence contract must be a JSON object"]}
|
|
1058
1210
|
errors: list[str] = []
|
|
1211
|
+
def parse_iso_utc_timestamp(value: object) -> datetime | None:
|
|
1212
|
+
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z", value):
|
|
1213
|
+
return None
|
|
1214
|
+
try:
|
|
1215
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
1216
|
+
except ValueError:
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
def is_iso_utc_timestamp(value: object) -> bool:
|
|
1220
|
+
return parse_iso_utc_timestamp(value) is not None
|
|
1221
|
+
|
|
1059
1222
|
if evidence.get("schemaVersion") != contract.get("schemaVersion"): errors.append("ops evidence schemaVersion is unsupported")
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1223
|
+
environment = evidence.get("environment")
|
|
1224
|
+
tenant = evidence.get("tenant")
|
|
1225
|
+
if not evidence.get("generatedAt") or not isinstance(environment, str) or not environment.strip() or not isinstance(tenant, str) or not tenant.strip(): errors.append("generatedAt, environment, and tenant are required")
|
|
1226
|
+
if not is_iso_utc_timestamp(evidence.get("generatedAt")):
|
|
1064
1227
|
errors.append("ops generatedAt must be an ISO timestamp")
|
|
1065
|
-
if require_host and
|
|
1228
|
+
if require_host and isinstance(environment, str) and environment.strip().lower() in {"fixture", "test", "local"}: errors.append("host evidence cannot use a fixture/local environment")
|
|
1066
1229
|
checks = evidence.get("checks") if isinstance(evidence.get("checks"), dict) else {}
|
|
1067
1230
|
def check_object(name: str) -> dict:
|
|
1068
1231
|
value = checks.get(name)
|
|
@@ -1080,10 +1243,14 @@ def ops_evidence_result(evidence: dict, contract: dict, require_host: bool = Fal
|
|
|
1080
1243
|
routed = check_object("alert_routing").get("alertCodes")
|
|
1081
1244
|
if not isinstance(routed, list) or not set(contract.get("requiredAlertCodes", [])).issubset(set(routed)): errors.append("alert routing does not cover every stable alert code")
|
|
1082
1245
|
for field in ("backupVerifiedAt", "restoreVerifiedAt"):
|
|
1083
|
-
|
|
1084
|
-
datetime.fromisoformat(str(check_object("backup_restore").get(field)).replace("Z", "+00:00"))
|
|
1085
|
-
except (TypeError, ValueError):
|
|
1246
|
+
if not is_iso_utc_timestamp(check_object("backup_restore").get(field)):
|
|
1086
1247
|
errors.append(f"backup_restore.{field} must be an ISO timestamp")
|
|
1248
|
+
generated_at = parse_iso_utc_timestamp(evidence.get("generatedAt"))
|
|
1249
|
+
backup_verified_at = parse_iso_utc_timestamp(check_object("backup_restore").get("backupVerifiedAt"))
|
|
1250
|
+
restore_verified_at = parse_iso_utc_timestamp(check_object("backup_restore").get("restoreVerifiedAt"))
|
|
1251
|
+
if generated_at is not None and backup_verified_at is not None and restore_verified_at is not None:
|
|
1252
|
+
if restore_verified_at < backup_verified_at: errors.append("backup_restore.restoreVerifiedAt must not precede backupVerifiedAt")
|
|
1253
|
+
if generated_at < restore_verified_at: errors.append("generatedAt must not precede restoreVerifiedAt")
|
|
1087
1254
|
if check_object("backup_restore").get("rowCountVerified") is not True: errors.append("backup_restore.rowCountVerified must be true")
|
|
1088
1255
|
if check_object("alert_routing").get("criticalDeliveryVerified") is not True: errors.append("alert_routing.criticalDeliveryVerified must be true")
|
|
1089
1256
|
for field in ("rollbackWriteGuardVerified", "workerRestartVerified", "outboxReplayVerified"):
|
|
@@ -1116,9 +1283,11 @@ def command_release_gate(args: argparse.Namespace) -> int:
|
|
|
1116
1283
|
access_result = access_contract_checks(load_json(access_path)) if access_path.is_file() else ["installed Booking access contract is missing"]
|
|
1117
1284
|
email_templates_path = root / "maggiedash" / "contracts" / "booking-email-templates-v1.json"
|
|
1118
1285
|
email_templates_result = email_templates_contract_checks(load_json(email_templates_path)) if email_templates_path.is_file() else ["installed Booking email templates contract is missing"]
|
|
1286
|
+
browser_contract_path = root / "maggiedash" / "contracts" / "booking-browser-evidence-v2.json"
|
|
1287
|
+
browser_contract = load_json(browser_contract_path) if browser_contract_path.is_file() else load_json(DEFAULT_BROWSER_EVIDENCE_CONTRACT)
|
|
1119
1288
|
stripe_path = root / "maggiedash" / "contracts" / "stripe-booking-capabilities-v1.json"
|
|
1120
1289
|
stripe_errors = stripe_contract_checks(load_json(stripe_path)) if stripe_path.is_file() else ["installed Stripe capability contract is missing"]
|
|
1121
|
-
checks = {"contract": not contract_result, "emailTemplatesContract": not email_templates_result, "stripeContract": not stripe_errors, "serviceAdapterTypes": (root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts").is_file(), "serviceContractValidator": (root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs").is_file(), "postgresManagerMethodsValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs").is_file(), "postgresPaymentAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs").is_file(), "postgresPaymentValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs").is_file(), "postgresCustomerAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs").is_file(), "postgresCustomerValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs").is_file(), "postgresBackupRestoreValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs").is_file(), "postgresCoreRepository": (root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs").is_file(), "workspace": (root / "_maggie" / "booking" / "booking.tsx").is_file(), "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file()}
|
|
1290
|
+
checks = {"contract": not contract_result, "emailTemplatesContract": not email_templates_result, "browserEvidenceContract": browser_contract_path.is_file(), "stripeContract": not stripe_errors, "serviceAdapterTypes": (root / "maggiedash" / "backend" / "booking-adapter-contract.d.ts").is_file(), "serviceContractValidator": (root / "maggiedash" / "scripts" / "validate-booking-service-contract.mjs").is_file(), "postgresManagerMethodsValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-manager-methods.mjs").is_file(), "postgresPaymentAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-payment-adapter.mjs").is_file(), "postgresPaymentValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-payment-adapter.mjs").is_file(), "postgresCustomerAdapter": (root / "maggiedash" / "backend" / "booking" / "postgres-customer-adapter.mjs").is_file(), "postgresCustomerValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-customer-adapter.mjs").is_file(), "postgresBackupRestoreValidator": (root / "maggiedash" / "scripts" / "validate-booking-postgres-backup-restore.integration.mjs").is_file(), "postgresCoreRepository": (root / "maggiedash" / "backend" / "booking" / "postgres-core-repository.mjs").is_file(), "workspace": (root / "_maggie" / "booking" / "booking.tsx").is_file(), "schema": (root / "maggiedash" / "schema" / "maggie-booking-install.sql").is_file()}
|
|
1122
1291
|
errors = list(contract_result)
|
|
1123
1292
|
errors.extend(f"access:{item}" for item in access_result)
|
|
1124
1293
|
errors.extend(f"emailTemplates:{item}" for item in email_templates_result)
|
|
@@ -1143,7 +1312,7 @@ def command_release_gate(args: argparse.Namespace) -> int:
|
|
|
1143
1312
|
browser_status = "failed"
|
|
1144
1313
|
errors.append(browser_error)
|
|
1145
1314
|
else:
|
|
1146
|
-
browser_status = browser_result(browser_evidence, browser_path.parent).get("status", "failed")
|
|
1315
|
+
browser_status = browser_result(browser_evidence, browser_path.parent, browser_contract).get("status", "failed")
|
|
1147
1316
|
if browser_status != "passed": errors.append("browser evidence failed")
|
|
1148
1317
|
elif args.require_browser:
|
|
1149
1318
|
errors.append("browser evidence is required")
|
|
@@ -1219,6 +1388,10 @@ def parser() -> argparse.ArgumentParser:
|
|
|
1219
1388
|
email_templates_contract = sub.add_parser("email-templates-contract", help="validate the tenant-scoped Booking email template contract")
|
|
1220
1389
|
email_templates_contract.add_argument("--contract", default=str(DEFAULT_EMAIL_TEMPLATES_CONTRACT))
|
|
1221
1390
|
email_templates_contract.set_defaults(func=command_email_templates_contract)
|
|
1391
|
+
voucher_contract = sub.add_parser("voucher-contract", help="validate the gift voucher lifecycle contract and an optional sanitized host ledger fixture")
|
|
1392
|
+
voucher_contract.add_argument("--contract", default=str(DEFAULT_VOUCHER_CONTRACT))
|
|
1393
|
+
voucher_contract.add_argument("--ledger", help="sanitized maggiedash-booking-voucher-ledger.v1 fixture to replay")
|
|
1394
|
+
voucher_contract.set_defaults(func=command_voucher_contract)
|
|
1222
1395
|
inspect = sub.add_parser("inspect", help="inspect Booking install artifacts and contracts")
|
|
1223
1396
|
inspect.add_argument("--project", default=".")
|
|
1224
1397
|
inspect.add_argument("--contract", default=str(DEFAULT_CONTRACT))
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Validate the Booking gift voucher lifecycle contract and host ledger fixtures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONTRACT_SCHEMA = "maggiedash-booking-voucher-lifecycle.v1"
|
|
9
|
+
LEDGER_SCHEMA = "maggiedash-booking-voucher-ledger.v1"
|
|
10
|
+
STATES = ["pending_payment", "active", "exhausted", "expired", "voided", "refunded"]
|
|
11
|
+
ENTRY_TYPES = ["issue", "redeem", "undo_redeem", "void", "expire", "refund"]
|
|
12
|
+
PERMISSIONS = ["vouchers.read", "vouchers.sell", "vouchers.redeem", "vouchers.void", "vouchers.refund"]
|
|
13
|
+
NOTIFICATIONS = ["voucher.purchase.confirmed", "voucher.delivered", "voucher.redeemed", "voucher.refunded", "voucher.expiry_reminder"]
|
|
14
|
+
ENDPOINTS = ["booking.vouchers.read", "booking.vouchers.sell", "booking.vouchers.redeem", "booking.vouchers.undo", "booking.vouchers.void", "booking.vouchers.refund"]
|
|
15
|
+
# A ledger fixture is evidence, not a code store: the full code must never appear.
|
|
16
|
+
FORBIDDEN_KEYS = {"code", "voucherCode", "rawCode", "secret", "hmacSecret"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def voucher_contract_checks(contract: dict) -> list[str]:
|
|
20
|
+
errors: list[str] = []
|
|
21
|
+
if contract.get("schemaVersion") != CONTRACT_SCHEMA: errors.append(f"schemaVersion must be {CONTRACT_SCHEMA}")
|
|
22
|
+
if contract.get("status") != "contract": errors.append("status must be contract")
|
|
23
|
+
runtime = contract.get("runtime") or {}
|
|
24
|
+
if runtime.get("owner") != "host" or runtime.get("maggieDashRuntime") is not False: errors.append("voucher runtime must be host-owned; MaggieDash ships no voucher runtime")
|
|
25
|
+
if contract.get("apiPrefix") != "/api/maggie/booking/vouchers.json": errors.append("apiPrefix must be /api/maggie/booking/vouchers.json")
|
|
26
|
+
auth = contract.get("auth") or {}
|
|
27
|
+
if auth.get("managerSession") != "host-session-cookie" or auth.get("emailPassword") is not True or auth.get("passkeys") is not False: errors.append("auth must use the host email/password session")
|
|
28
|
+
if contract.get("permissions") != PERMISSIONS: errors.append("voucher permissions are incomplete or out of order")
|
|
29
|
+
money = contract.get("money") or {}
|
|
30
|
+
if money.get("unit") != "minor" or money.get("integerOnly") is not True: errors.append("voucher money must be integer minor units")
|
|
31
|
+
channels = contract.get("channels") or {}
|
|
32
|
+
online, till = channels.get("online") or {}, channels.get("till") or {}
|
|
33
|
+
if online.get("provider") != "stripe" or online.get("nativeGiftCards") is not False: errors.append("online vouchers must be funded by Stripe payments with a local ledger, not a native gift card")
|
|
34
|
+
if not till.get("paymentMethods"): errors.append("till vouchers must declare recorded payment methods")
|
|
35
|
+
code_format = contract.get("codeFormat") or {}
|
|
36
|
+
if (code_format.get("online") or {}).get("check") != "hmac-sha256": errors.append("online codes must carry an HMAC check segment")
|
|
37
|
+
if not (code_format.get("till") or {}).get("check"): errors.append("till codes must carry a check digit")
|
|
38
|
+
if "codeHash" not in str(code_format.get("storage", "")): errors.append("code storage must keep only a hash and last four characters")
|
|
39
|
+
if contract.get("states") != STATES: errors.append("voucher states are incomplete or out of order")
|
|
40
|
+
ledger = contract.get("ledger") or {}
|
|
41
|
+
if ledger.get("appendOnly") is not True: errors.append("voucher ledger must be append-only")
|
|
42
|
+
if ledger.get("entryTypes") != ENTRY_TYPES: errors.append("voucher ledger entry types are incomplete or out of order")
|
|
43
|
+
rules = contract.get("rules") or {}
|
|
44
|
+
for rule in ("issue", "partialRedemption", "undo", "void", "expiry", "refund", "fullRefund"):
|
|
45
|
+
if not str(rules.get(rule, "")).strip(): errors.append(f"voucher rule is missing: {rule}")
|
|
46
|
+
notifications = [item.get("id") for item in contract.get("notifications", []) if isinstance(item, dict)]
|
|
47
|
+
if notifications != NOTIFICATIONS: errors.append("voucher notification events are incomplete or out of order")
|
|
48
|
+
if contract.get("endpointIds") != ENDPOINTS: errors.append("voucher endpoint IDs are incomplete or out of order")
|
|
49
|
+
routes = contract.get("canonicalRoutes") or {}
|
|
50
|
+
if set(routes) != set(ENDPOINTS): errors.append("voucher canonical routes are incomplete")
|
|
51
|
+
policy = contract.get("policy") or {}
|
|
52
|
+
for field in ("tenantScoped", "auditRequired", "idempotencyRequiredForMutations", "fullCodeOnlyInDeliveryEmail", "providerCredentialsRemainServerOnly"):
|
|
53
|
+
if policy.get(field) is not True: errors.append(f"voucher policy {field} must be true")
|
|
54
|
+
return errors
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _time(value: object) -> datetime | None:
|
|
58
|
+
try:
|
|
59
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
60
|
+
except ValueError:
|
|
61
|
+
return None
|
|
62
|
+
return parsed if parsed.tzinfo else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _forbidden_paths(value: object, path: str = "") -> list[str]:
|
|
66
|
+
found: list[str] = []
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
for key, item in value.items():
|
|
69
|
+
child = f"{path}.{key}" if path else key
|
|
70
|
+
if key in FORBIDDEN_KEYS: found.append(child)
|
|
71
|
+
found.extend(_forbidden_paths(item, child))
|
|
72
|
+
elif isinstance(value, list):
|
|
73
|
+
for index, item in enumerate(value):
|
|
74
|
+
found.extend(_forbidden_paths(item, f"{path}[{index}]"))
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def replay_voucher(voucher: dict, now: datetime | None = None) -> tuple[dict, list[str]]:
|
|
79
|
+
"""Replay one voucher ledger and return its derived state plus rule violations."""
|
|
80
|
+
vid = str(voucher.get("id") or "unknown")
|
|
81
|
+
errors: list[str] = []
|
|
82
|
+
face = voucher.get("faceValue")
|
|
83
|
+
channel = voucher.get("channel")
|
|
84
|
+
if channel not in ("online", "till"): errors.append(f"{vid}: channel must be online or till")
|
|
85
|
+
if not isinstance(face, int) or isinstance(face, bool) or face <= 0: errors.append(f"{vid}: faceValue must be a positive integer in minor units")
|
|
86
|
+
expires_at = _time(voucher.get("expiresAt")) if voucher.get("expiresAt") else None
|
|
87
|
+
if voucher.get("expiresAt") and expires_at is None: errors.append(f"{vid}: expiresAt must be an ISO timestamp with a timezone")
|
|
88
|
+
entries = voucher.get("entries")
|
|
89
|
+
if not isinstance(entries, list) or not entries:
|
|
90
|
+
return {"id": vid, "balance": 0, "status": None}, errors + [f"{vid}: ledger entries are required"]
|
|
91
|
+
balance = funded = refunded = 0
|
|
92
|
+
paid = channel == "till"
|
|
93
|
+
voided = expired = False
|
|
94
|
+
redeems: dict[str, int] = {}
|
|
95
|
+
undone: set[str] = set()
|
|
96
|
+
redeemed_any = False
|
|
97
|
+
previous: datetime | None = None
|
|
98
|
+
seen_ids: set[str] = set()
|
|
99
|
+
for index, entry in enumerate(entries):
|
|
100
|
+
label = f"{vid}: entry {entry.get('id') or index}"
|
|
101
|
+
kind, amount, at = entry.get("type"), entry.get("amount"), _time(entry.get("at"))
|
|
102
|
+
entry_id = str(entry.get("id") or "")
|
|
103
|
+
if not entry_id or entry_id in seen_ids: errors.append(f"{label}: entry id must be present and unique")
|
|
104
|
+
seen_ids.add(entry_id)
|
|
105
|
+
if kind not in ENTRY_TYPES: errors.append(f"{label}: unsupported entry type"); continue
|
|
106
|
+
if at is None: errors.append(f"{label}: at must be an ISO timestamp with a timezone"); continue
|
|
107
|
+
if previous and at < previous: errors.append(f"{label}: ledger entries must be in chronological order")
|
|
108
|
+
previous = at
|
|
109
|
+
if kind != "issue" and kind not in ("void", "expire") and (not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0):
|
|
110
|
+
errors.append(f"{label}: amount must be a positive integer"); continue
|
|
111
|
+
if kind != "issue" and not str(entry.get("idempotencyKey", "")).strip(): errors.append(f"{label}: mutations require an idempotencyKey")
|
|
112
|
+
if index == 0 and kind != "issue": errors.append(f"{label}: the first entry must be issue"); continue
|
|
113
|
+
if kind == "issue":
|
|
114
|
+
if index != 0: errors.append(f"{label}: only one issue entry is allowed"); continue
|
|
115
|
+
if amount != face: errors.append(f"{label}: issue amount must equal faceValue")
|
|
116
|
+
balance = funded = face if isinstance(face, int) else 0
|
|
117
|
+
if channel == "online":
|
|
118
|
+
paid = bool(entry.get("paymentRef"))
|
|
119
|
+
if not paid: errors.append(f"{label}: online issue requires the verified provider paymentRef")
|
|
120
|
+
continue
|
|
121
|
+
if voided: errors.append(f"{label}: no {kind} is allowed after void"); continue
|
|
122
|
+
if kind == "void":
|
|
123
|
+
if not str(entry.get("reason", "")).strip(): errors.append(f"{label}: void requires a reason")
|
|
124
|
+
voided = True
|
|
125
|
+
continue
|
|
126
|
+
if kind == "expire":
|
|
127
|
+
if expires_at is None or at < expires_at: errors.append(f"{label}: expire before expiresAt")
|
|
128
|
+
expired = True
|
|
129
|
+
continue
|
|
130
|
+
if kind == "redeem":
|
|
131
|
+
if expires_at is not None and at >= expires_at: errors.append(f"{label}: redemption at or after expiry is blocked"); continue
|
|
132
|
+
if not paid: errors.append(f"{label}: redemption before payment is blocked"); continue
|
|
133
|
+
if amount > balance: errors.append(f"{label}: redemption exceeds the remaining balance"); continue
|
|
134
|
+
balance -= amount
|
|
135
|
+
redeems[entry_id] = amount
|
|
136
|
+
redeemed_any = True
|
|
137
|
+
continue
|
|
138
|
+
if kind == "undo_redeem":
|
|
139
|
+
target = str(entry.get("reverses") or "")
|
|
140
|
+
if target not in redeems: errors.append(f"{label}: undo must reverse an earlier redeem entry"); continue
|
|
141
|
+
if target in undone: errors.append(f"{label}: a redemption can be undone only once"); continue
|
|
142
|
+
if amount != redeems[target]: errors.append(f"{label}: undo amount must equal the reversed redemption"); continue
|
|
143
|
+
undone.add(target)
|
|
144
|
+
balance += amount
|
|
145
|
+
continue
|
|
146
|
+
if kind == "refund":
|
|
147
|
+
if amount > balance: errors.append(f"{label}: refund exceeds the remaining balance"); continue
|
|
148
|
+
if refunded + amount > funded: errors.append(f"{label}: refund exceeds the funded amount"); continue
|
|
149
|
+
if channel == "online" and not entry.get("paymentRef"): errors.append(f"{label}: online refunds must reference the original provider payment")
|
|
150
|
+
if channel == "till" and not entry.get("refundMethod"): errors.append(f"{label}: till refunds must record refundMethod")
|
|
151
|
+
balance -= amount
|
|
152
|
+
refunded += amount
|
|
153
|
+
if balance < 0: errors.append(f"{vid}: balance became negative")
|
|
154
|
+
# Status is evaluated at the later of the fixture clock and the ledger's own last entry.
|
|
155
|
+
now = now or datetime.now().astimezone()
|
|
156
|
+
if previous and previous > now: now = previous
|
|
157
|
+
if voided: status = "voided"
|
|
158
|
+
elif not paid: status = "pending_payment"
|
|
159
|
+
elif refunded and balance == 0 and not redeemed_any: status = "refunded"
|
|
160
|
+
elif balance == 0: status = "exhausted"
|
|
161
|
+
elif expired or (expires_at is not None and now >= expires_at): status = "expired"
|
|
162
|
+
else: status = "active"
|
|
163
|
+
declared = voucher.get("status")
|
|
164
|
+
if declared is not None and declared != status: errors.append(f"{vid}: declared status {declared} does not match the ledger ({status})")
|
|
165
|
+
declared_balance = voucher.get("balance")
|
|
166
|
+
if declared_balance is not None and declared_balance != balance: errors.append(f"{vid}: declared balance {declared_balance} does not match the ledger ({balance})")
|
|
167
|
+
return {"id": vid, "balance": balance, "status": status, "refunded": refunded}, errors
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def voucher_ledger_checks(fixture: dict) -> tuple[list[dict], list[str]]:
|
|
171
|
+
errors: list[str] = []
|
|
172
|
+
if fixture.get("schemaVersion") != LEDGER_SCHEMA: errors.append(f"ledger schemaVersion must be {LEDGER_SCHEMA}")
|
|
173
|
+
errors.extend(f"ledger must not contain full codes or secrets: {path}" for path in _forbidden_paths(fixture))
|
|
174
|
+
now = _time(fixture.get("now")) if fixture.get("now") else None
|
|
175
|
+
if fixture.get("now") and now is None: errors.append("ledger now must be an ISO timestamp with a timezone")
|
|
176
|
+
vouchers = fixture.get("vouchers")
|
|
177
|
+
if not isinstance(vouchers, list) or not vouchers:
|
|
178
|
+
return [], errors + ["ledger must contain at least one voucher"]
|
|
179
|
+
results = []
|
|
180
|
+
for voucher in vouchers:
|
|
181
|
+
if not isinstance(voucher, dict): errors.append("voucher entries must be objects"); continue
|
|
182
|
+
result, voucher_errors = replay_voucher(voucher, now)
|
|
183
|
+
results.append(result)
|
|
184
|
+
errors.extend(voucher_errors)
|
|
185
|
+
return results, errors
|