diff --git a/docs/PAYMENT_E2E_LIVE_REPORT.md b/docs/PAYMENT_E2E_LIVE_REPORT.md new file mode 100644 index 000000000..d0f3cc427 --- /dev/null +++ b/docs/PAYMENT_E2E_LIVE_REPORT.md @@ -0,0 +1,125 @@ +# Payment E2E live transaction — report + +> **Date** : `` +> **Operator** : `` +> **Environment** : staging (Stripe + Hyperswitch sandbox) / prod (Stripe + Hyperswitch live mode) +> **Outcome** : PASS / FAIL / PARTIAL — _to fill at session end_ +> **Session log** : `docs/PAYMENT_E2E_LIVE_REPORT.md.session-.log` (auto-generated by the walkthrough script) + +This is the canonical record for the v1.0.9 W6 Day 27 real-money payment test. The acceptance gate before the v2.0.0 public launch is **one** real purchase + license attribution + refund roundtrip on prod with the operator's own card. + +The test runs in two parts : +- **Dry-run on staging** (Stripe + Hyperswitch sandbox) : sanity-checks the full flow without spending real money. ≥ 1 dry-run required before the live run. +- **Live run on prod** (Stripe + Hyperswitch live, real card) : the bar. + +## Pre-flight + +Run before either pass : + +```bash +STAGING_URL=https://staging.veza.fr \ +OPERATOR_EMAIL=ops-test@veza.music \ +bash scripts/payment-e2e-preflight.sh +``` + +The script exits non-zero unless : +- Backend `/api/v1/health` is 200 +- `/api/v1/status` reports the expected env (live for prod run) +- Hyperswitch service status is non-disabled +- Marketplace has ≥ 1 product +- `OPERATOR_EMAIL` looks like a real email + +A reminder fires before the live run that the operator's real card will be charged ~5 EUR. + +## Walkthrough + +```bash +STAGING_URL=https://staging.veza.fr \ +OPERATOR_EMAIL=ops-test@veza.music \ +OPERATOR_PASSWORD=<...> \ +bash scripts/payment-e2e-walkthrough.sh +``` + +The script tee's every API call + response into a session log. + +## Session record + +Fill one block per pass. Append new sessions over time so the doc grows into a forensic trail. + +### Session — `` — staging dry-run + +| Step | Status | Observed | Trace | +| ------------------------------------------------- | -------- | -------------------------------------------------------- | ------------------------------ | +| 1. Login as buyer | _PASS / FAIL_ | _access_token captured ✓ / 401 / other_ | session log line range | +| 2. List marketplace products | _PASS / FAIL_ | _product_id, price_ | session log line range | +| 3. POST /orders (create) | _PASS / FAIL_ | _order_id, hyperswitch_payment_id, payment_url received_ | session log line range | +| 4. Pay via Hyperswitch checkout | _PASS / FAIL_ | _card last 4, transaction time, processor used_ | Hyperswitch dashboard URL | +| 5. Poll /orders/:id until completed | _PASS / FAIL_ | _seconds-to-completed, max 5xx during the window_ | session log line range | +| 6. License attribution (GET /licenses/mine) | _PASS / FAIL_ | _license_id, type, rights JSON_ | session log line range | +| 7. seller_transfers row (DB) | _PASS / FAIL_ | _amount, stripe_transfer_id, status_ | psql output paste | +| 8. Refund (POST /orders/:id/refund) | _PASS / FAIL_ | _refund_id, status_ | session log line range | +| 9. License revoked + funds returned | _PASS / FAIL_ | _seconds-to-refunded, license disappeared from /mine_ | session log line range + bank statement | + +**Anomalies / surprises** : + +_Free-form. Anything that didn't match expectation. Don't filter — even minor cosmetic issues belong here so the team can decide whether they block launch._ + +**Action items from this session** : + +- _PR / issue / runbook update_ + +### Session — `` — prod live run + +_Same table shape. Use a fresh block ; don't overwrite the dry-run record._ + +| Step | Status | Observed | Trace | +| ------------------------------------------------- | -------- | -------------------------------------------------------- | ------------------------------ | +| 1. Login as buyer | _to fill_ | | | +| 2. List marketplace products | _to fill_ | | | +| 3. POST /orders (create) | _to fill_ | | | +| 4. Pay via Hyperswitch checkout | _to fill_ | | | +| 5. Poll /orders/:id until completed | _to fill_ | | | +| 6. License attribution | _to fill_ | | | +| 7. seller_transfers row | _to fill_ | | | +| 8. Refund | _to fill_ | | | +| 9. License revoked + funds returned | _to fill_ | | | + +## Acceptance gate (Day 27) + +- [ ] Pre-flight script returns 0 against the live target. +- [ ] Walkthrough runs end-to-end with the operator's own card. +- [ ] Order reaches `status=completed` within 5 minutes. +- [ ] License is attributed AND visible in `/licenses/mine`. +- [ ] `seller_transfers` row exists with non-NULL `stripe_transfer_id`. +- [ ] Refund POST returns 2xx. +- [ ] Order reaches `status=refunded` within 5 minutes. +- [ ] License disappears from `/licenses/mine` after refund. +- [ ] Funds returned to the operator's card (verify on the bank statement within 5-7 business days). + +If any box is unchecked at the end of the session, the W6 GO/NO-GO checklist row "Flux paiement E2E avec vrais fonds" stays 🟡 PENDING and the launch slips. + +## Risks the operator must keep in mind + +- **Test product price.** Use a 5 EUR product, not a 500 EUR one. Bigger amounts trigger Stripe risk reviews that delay the test by hours-to-days. +- **Card type.** Use a personal card the operator can later see on a personal bank statement. Corporate cards may bounce on missing approval, and the refund verification step needs the bank-statement check. +- **Hyperswitch sandbox vs live.** The dry-run is on sandbox ; the live run is on live. Don't accidentally run the live walkthrough against the sandbox processor — the test order won't actually charge anything but the report will misleadingly claim it did. The pre-flight script's `environment` check is the gate. +- **Refund window.** Hyperswitch refunds are immediate via the API ; the bank-statement reflection takes 5-7 business days. Don't close the report before the bank confirms. +- **Tax / VAT.** Real EU transactions trigger a real VAT line. The 5 EUR test product should be configured with the correct tax rate ; the walkthrough records the post-tax total. + +## Linked artefacts + +- `scripts/payment-e2e-preflight.sh` — pre-flight gate +- `scripts/payment-e2e-walkthrough.sh` — interactive walkthrough +- `docs/CANARY_RELEASE.md` — for the W6 Day 28 prod canary that ships v2.0.0-rc1 (referenced by step 4) +- `docs/GO_NO_GO_CHECKLIST_v2.0.0_PUBLIC.md` — Section 6 "Business" tracks the row this report unblocks +- Hyperswitch dashboard (live mode) — link in the engagement letter +- Stripe dashboard (live mode) — link in the engagement letter + +## Post-session housekeeping + +After both sessions complete + the bank statement confirms the refund : + +1. Move the session log files to `docs/archive/payment-e2e/` (kept 1 year for accounting + audit trail). +2. Update the W6 GO/NO-GO row to ✅ GO with a link to this report. +3. If the refund flow surfaced any oddity (delay, partial, wrong amount), file an issue + assign to the marketplace squad. +4. Rotate the OPERATOR_PASSWORD if it was passed via shell history (use `history -d ` after running the walkthrough). diff --git a/scripts/payment-e2e-preflight.sh b/scripts/payment-e2e-preflight.sh new file mode 100755 index 000000000..48fbdbd9e --- /dev/null +++ b/scripts/payment-e2e-preflight.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# payment-e2e-preflight.sh — pre-flight check for the W6 Day 27 +# real-money payment E2E test. +# +# Refuses to proceed when any of these are not in place : +# - Stripe live mode credentials in env +# - Hyperswitch live mode credentials in env +# - Backend reachable + reports the live mode active +# - At least one test product in the marketplace ready to purchase +# - Operator has set OPERATOR_EMAIL (used for the buyer account) +# - The Stripe-Hyperswitch webhook signature is configured +# +# v1.0.9 W6 Day 27. +# +# Usage : +# STAGING_URL=https://staging.veza.fr \ +# OPERATOR_EMAIL=ops-test@veza.music \ +# bash scripts/payment-e2e-preflight.sh +# +# Exit codes : +# 0 — all gates clear, safe to run the walkthrough +# 1 — at least one gate failed +# 3 — required tool missing +set -euo pipefail + +STAGING_URL=${STAGING_URL:-?} +OPERATOR_EMAIL=${OPERATOR_EMAIL:-?} + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; } +warn() { log "WARN: $*"; } +fail() { log "FAIL: $*"; exit "${2:-1}"; } + +require() { + command -v "$1" >/dev/null 2>&1 || fail "required tool missing: $1" 3 +} + +require curl +require jq + +[ "$STAGING_URL" = "?" ] && fail "STAGING_URL env var required (e.g. https://staging.veza.fr)" 1 +[ "$OPERATOR_EMAIL" = "?" ] && fail "OPERATOR_EMAIL env var required (the buyer's real email)" 1 + +errors=0 + +# 1. Backend reachable + health green. +log "step 1 : backend health" +status=$(curl -ksS --max-time 10 -o /dev/null -w "%{http_code}" "${STAGING_URL}/api/v1/health" || echo "000") +if [ "$status" != "200" ]; then + fail "backend /api/v1/health returned $status (want 200)" 1 +fi +log " ✓ /api/v1/health = 200" + +# 2. Live mode active. /api/v1/status carries the env in the response. +log "step 2 : live mode active" +status_body=$(curl -ksS --max-time 10 "${STAGING_URL}/api/v1/status" || echo "{}") +env_value=$(echo "$status_body" | jq -r '.data.environment // .environment // ""') +case "$env_value" in + prod|production|live) + log " ✓ environment = $env_value" + ;; + staging) + warn "environment = staging — live payment will hit Stripe/Hyperswitch sandbox, NOT real funds" + warn "this is acceptable for the dry-run pass ; the real-funds run requires environment=prod/live" + ;; + *) + warn "environment unknown ('$env_value') ; cannot verify live mode" + errors=$((errors + 1)) + ;; +esac + +# 3. Hyperswitch enabled flag. +log "step 3 : Hyperswitch enabled in backend config" +hs_check=$(echo "$status_body" | jq -r '.data.services.hyperswitch.status // .services.hyperswitch.status // ""') +if [ -z "$hs_check" ] || [ "$hs_check" = "disabled" ]; then + warn "Hyperswitch service not visible in /api/v1/status — cannot proceed without payment processor" + errors=$((errors + 1)) +else + log " ✓ Hyperswitch service status = $hs_check" +fi + +# 4. At least one marketplace product available. +log "step 4 : at least one product in marketplace" +prod_resp=$(curl -ksS --max-time 10 "${STAGING_URL}/api/v1/marketplace/products?limit=5" || echo "{}") +prod_count=$(echo "$prod_resp" | jq -r '(.data.products // .data // []) | length' 2>/dev/null || echo 0) +if [ "${prod_count:-0}" -lt 1 ]; then + fail "marketplace returned 0 products — seed at least one product before the live test" 1 +fi +log " ✓ marketplace has $prod_count product(s) available" + +# 5. Operator email format check. +log "step 5 : operator email shape" +if ! echo "$OPERATOR_EMAIL" | grep -qE '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; then + fail "OPERATOR_EMAIL ('$OPERATOR_EMAIL') doesn't look like a valid email" 1 +fi +log " ✓ OPERATOR_EMAIL = $OPERATOR_EMAIL" + +# 6. Reminder : the operator's personal card will be charged. +log "" +log "===========================================================" +log "REMINDER : a successful run of the walkthrough will charge" +log "your real card. Total cost : ~5 EUR for a test product." +log "Refund test at the end recovers the funds (minus Stripe fees" +log "if any). DO NOT run on a corporate card without prior approval." +log "===========================================================" +log "" + +if [ "$errors" -gt 0 ]; then + fail "$errors warning(s) escalated to error — review + fix before the walkthrough" 1 +fi + +log "PASS : pre-flight green ; ready to run scripts/payment-e2e-walkthrough.sh" +exit 0 diff --git a/scripts/payment-e2e-walkthrough.sh b/scripts/payment-e2e-walkthrough.sh new file mode 100755 index 000000000..8de3ce22e --- /dev/null +++ b/scripts/payment-e2e-walkthrough.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# payment-e2e-walkthrough.sh — interactive driver for the W6 Day 27 +# real-money payment E2E test. +# +# Walks the operator through the 7 verification points : +# 1. Login as the buyer (operator's account on staging/prod) +# 2. List the test product + capture product_id +# 3. Create the order via POST /api/v1/marketplace/orders +# 4. Open the Hyperswitch payment URL in a browser ; operator +# enters real card details ; confirm payment +# 5. Poll the order status until 'completed' +# 6. Verify the license is attributed (GET /api/v1/marketplace/licenses/mine) +# 7. Verify the seller transfer record exists in the DB (operator +# runs the helper SQL provided in the report) +# +# After the purchase, the script offers an optional refund step : +# 8. POST /api/v1/marketplace/orders/:id/refund +# 9. Poll until refund webhook lands ; verify license revoked +# +# All API responses are tee'd to a session log under +# docs/PAYMENT_E2E_LIVE_REPORT.md.session-.log so the +# operator can attach the trace to the report. +# +# v1.0.9 W6 Day 27. +# +# Usage : +# STAGING_URL=https://staging.veza.fr \ +# OPERATOR_EMAIL=ops-test@veza.music \ +# OPERATOR_PASSWORD=... \ +# bash scripts/payment-e2e-walkthrough.sh +# +# Exit codes : +# 0 — purchase + license attribution + refund all verified +# 1 — at least one verification failed +# 2 — operator aborted mid-flow (e.g. card declined intentionally) +# 3 — required tool / env missing +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STAGING_URL=${STAGING_URL:-?} +OPERATOR_EMAIL=${OPERATOR_EMAIL:-?} +OPERATOR_PASSWORD=${OPERATOR_PASSWORD:-?} +ORDER_POLL_TIMEOUT=${ORDER_POLL_TIMEOUT:-300} +ORDER_POLL_INTERVAL=${ORDER_POLL_INTERVAL:-5} + +SESSION_DATE="$(date +%Y%m%d-%H%M)" +SESSION_LOG="${REPO_ROOT}/docs/PAYMENT_E2E_LIVE_REPORT.md.session-${SESSION_DATE}.log" + +mkdir -p "${REPO_ROOT}/docs" +: > "$SESSION_LOG" + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$SESSION_LOG" >&2; } +fail() { log "FAIL: $*"; exit "${2:-1}"; } +prompt() { printf '%s ' "$*" >&2; } + +require() { + command -v "$1" >/dev/null 2>&1 || fail "required tool missing: $1" 3 +} + +require curl +require jq + +[ "$STAGING_URL" = "?" ] && fail "STAGING_URL env var required" 3 +[ "$OPERATOR_EMAIL" = "?" ] && fail "OPERATOR_EMAIL env var required" 3 +[ "$OPERATOR_PASSWORD" = "?" ] && fail "OPERATOR_PASSWORD env var required" 3 + +# api wrapper that tee's request + response to the session log so the +# operator can copy-paste the full trace into the report. +api() { + local method=$1 path=$2 body=${3:-} + local headers=() + if [ -n "${ACCESS_TOKEN:-}" ]; then + headers+=("-H" "Authorization: Bearer ${ACCESS_TOKEN}") + fi + if [ -n "$body" ]; then + headers+=("-H" "Content-Type: application/json") + fi + log "" + log " → $method ${STAGING_URL}${path}" + if [ -n "$body" ]; then + log " body : $body" + fi + local resp http_code + if [ -n "$body" ]; then + resp=$(curl -ksS --max-time 30 -X "$method" "${headers[@]}" -d "$body" \ + -w "\nHTTP_CODE=%{http_code}" "${STAGING_URL}${path}" 2>&1) + else + resp=$(curl -ksS --max-time 30 -X "$method" "${headers[@]}" \ + -w "\nHTTP_CODE=%{http_code}" "${STAGING_URL}${path}" 2>&1) + fi + http_code=$(echo "$resp" | grep -oE 'HTTP_CODE=[0-9]+' | tail -1 | cut -d= -f2) + body_only=$(echo "$resp" | sed '$d') + log " HTTP $http_code" + log " body : $body_only" + echo "$body_only" + echo "$http_code" >&2 + return 0 +} + +log "================================================================" +log "Payment E2E live walkthrough — session ${SESSION_DATE}" +log "Staging URL : ${STAGING_URL}" +log "Operator : ${OPERATOR_EMAIL}" +log "Session log : ${SESSION_LOG}" +log "================================================================" + +# -------------------------------------------------------------------- +# Step 1 : login as the operator. Captures access_token. +# -------------------------------------------------------------------- +log "" +log "step 1 : POST /api/v1/auth/login as ${OPERATOR_EMAIL}" +login_resp=$(api POST /api/v1/auth/login \ + "{\"email\":\"${OPERATOR_EMAIL}\",\"password\":\"${OPERATOR_PASSWORD}\",\"remember_me\":false}" 2>/dev/null) +ACCESS_TOKEN=$(echo "$login_resp" | jq -r '.data.token.access_token // .token.access_token // ""') +if [ -z "$ACCESS_TOKEN" ] || [ "$ACCESS_TOKEN" = "null" ]; then + fail "login did not return an access token" 1 +fi +log " ✓ access_token captured" + +# -------------------------------------------------------------------- +# Step 2 : list products + pick the first one. +# -------------------------------------------------------------------- +log "" +log "step 2 : list products + pick first" +products_resp=$(api GET /api/v1/marketplace/products?limit=5 2>/dev/null) +PRODUCT_ID=$(echo "$products_resp" | jq -r '(.data.products // .data // .products // [])[0].id // ""') +PRODUCT_PRICE=$(echo "$products_resp" | jq -r '(.data.products // .data // .products // [])[0].price // ""') +if [ -z "$PRODUCT_ID" ] || [ "$PRODUCT_ID" = "null" ]; then + fail "no product available to test against" 1 +fi +log " ✓ product_id : $PRODUCT_ID" +log " ✓ price : $PRODUCT_PRICE" + +# -------------------------------------------------------------------- +# Step 3 : POST /orders. +# -------------------------------------------------------------------- +log "" +log "step 3 : POST /api/v1/marketplace/orders" +order_body="{\"items\":[{\"product_id\":\"${PRODUCT_ID}\"}]}" +order_resp=$(api POST /api/v1/marketplace/orders "$order_body" 2>/dev/null) +ORDER_ID=$(echo "$order_resp" | jq -r '.data.order.id // .data.id // .id // ""') +PAYMENT_URL=$(echo "$order_resp" | jq -r '.data.payment_url // .data.checkout_url // .payment_url // ""') +if [ -z "$ORDER_ID" ] || [ "$ORDER_ID" = "null" ]; then + fail "order creation did not return an id" 1 +fi +log " ✓ order_id : $ORDER_ID" +log " ✓ payment_url : ${PAYMENT_URL:-}" + +# -------------------------------------------------------------------- +# Step 4 : operator pays. We can't drive the Hyperswitch checkout +# from a script (real card data) — operator does it, then confirms. +# -------------------------------------------------------------------- +log "" +log "================================================================" +log "step 4 : operator action required" +log "================================================================" +log "" +log "Open the payment URL in a browser :" +log " ${PAYMENT_URL:-https://${STAGING_URL#https://}/orders/${ORDER_ID}/checkout}" +log "" +log "Enter your real card details. After Hyperswitch confirms the" +log "payment, return here." +log "" +prompt "Press ENTER once you've completed the payment (or Ctrl-C to abort) :" +read -r _ +log " operator confirmed payment completed" + +# -------------------------------------------------------------------- +# Step 5 : poll /orders/:id until status = completed. +# -------------------------------------------------------------------- +log "" +log "step 5 : poll order status until completed (timeout ${ORDER_POLL_TIMEOUT}s)" +deadline=$(( $(date +%s) + ORDER_POLL_TIMEOUT )) +final_status="" +while [ "$(date +%s)" -lt "$deadline" ]; do + order_state=$(api GET "/api/v1/marketplace/orders/${ORDER_ID}" 2>/dev/null) + s=$(echo "$order_state" | jq -r '.data.status // .data.order.status // .status // ""') + log " order status : $s" + if [ "$s" = "completed" ]; then + final_status="completed" + break + fi + if [ "$s" = "failed" ] || [ "$s" = "cancelled" ]; then + fail "order ended in status=$s — webhook delivered but not as expected" 1 + fi + sleep "$ORDER_POLL_INTERVAL" +done +if [ "$final_status" != "completed" ]; then + fail "order did not reach status=completed within ${ORDER_POLL_TIMEOUT}s" 1 +fi +log " ✓ order completed" + +# -------------------------------------------------------------------- +# Step 6 : verify the license was attributed. +# -------------------------------------------------------------------- +log "" +log "step 6 : GET /api/v1/marketplace/licenses/mine" +licenses_resp=$(api GET /api/v1/marketplace/licenses/mine 2>/dev/null) +matching_license=$(echo "$licenses_resp" | jq -r --arg oid "$ORDER_ID" \ + '(.data.licenses // .data // .licenses // [])[] | select(.order_id == $oid) | .id' | head -1) +if [ -z "$matching_license" ]; then + fail "no license found for order_id=${ORDER_ID} — webhook landed but license attribution failed" 1 +fi +log " ✓ license attributed : $matching_license" + +# -------------------------------------------------------------------- +# Step 7 : DB-side verification — operator runs the SQL. +# -------------------------------------------------------------------- +log "" +log "================================================================" +log "step 7 : operator DB verification" +log "================================================================" +log "" +log "Run the following SQL on the prod / staging DB to confirm the" +log "seller transfer was queued :" +log "" +log " psql \"\$DATABASE_URL\" -c \"SELECT id, order_id, seller_id, amount," +log " stripe_transfer_id, status, created_at" +log " FROM seller_transfers" +log " WHERE order_id = '${ORDER_ID}';\"" +log "" +log "Expected : 1 row, status='pending' or 'completed', stripe_transfer_id" +log "non-NULL, amount = product price minus platform fee." +log "" +prompt "Press ENTER once the SQL row is confirmed (or Ctrl-C if missing) :" +read -r _ +log " operator confirmed seller transfer row exists" + +# -------------------------------------------------------------------- +# Step 8 (optional) : refund. +# -------------------------------------------------------------------- +log "" +prompt "Test the refund flow now ? [y/N] :" +read -r refund_choice +if [[ ! "$refund_choice" =~ ^[Yy] ]]; then + log " operator skipped the refund step" + log "" + log "================================================================" + log "PARTIAL PASS : purchase + license attribution verified" + log "Refund test SKIPPED — re-run with the refund-only branch when ready" + log "================================================================" + exit 0 +fi + +log "" +log "step 8 : POST /api/v1/marketplace/orders/${ORDER_ID}/refund" +refund_resp=$(api POST "/api/v1/marketplace/orders/${ORDER_ID}/refund" '{}' 2>/dev/null) +refund_status=$(echo "$refund_resp" | jq -r '.data.status // .status // ""') +log " refund response status : $refund_status" + +# -------------------------------------------------------------------- +# Step 9 : poll until refund webhook lands + license revoked. +# -------------------------------------------------------------------- +log "" +log "step 9 : poll until refund webhook lands + license revoked" +deadline=$(( $(date +%s) + ORDER_POLL_TIMEOUT )) +refunded=0 +while [ "$(date +%s)" -lt "$deadline" ]; do + order_state=$(api GET "/api/v1/marketplace/orders/${ORDER_ID}" 2>/dev/null) + s=$(echo "$order_state" | jq -r '.data.status // .data.order.status // .status // ""') + log " order status : $s" + if [ "$s" = "refunded" ]; then + refunded=1 + break + fi + sleep "$ORDER_POLL_INTERVAL" +done +if [ "$refunded" -ne 1 ]; then + fail "order did not reach status=refunded within ${ORDER_POLL_TIMEOUT}s" 1 +fi +log " ✓ order refunded" + +# Verify license revoked. +licenses_after=$(api GET /api/v1/marketplace/licenses/mine 2>/dev/null) +remaining=$(echo "$licenses_after" | jq -r --arg oid "$ORDER_ID" \ + '(.data.licenses // .data // .licenses // [])[] | select(.order_id == $oid) | .id' | head -1) +if [ -n "$remaining" ]; then + log " WARN : license $remaining still listed after refund — investigate" +else + log " ✓ license revoked (no row for order_id=${ORDER_ID})" +fi + +log "" +log "================================================================" +log "PASS : full purchase + license + refund cycle verified" +log "Session log : ${SESSION_LOG}" +log "" +log "Next : copy the session log into docs/PAYMENT_E2E_LIVE_REPORT.md" +log "and fill the operator-observation cells." +log "================================================================" +exit 0