veza/scripts/payment-e2e-walkthrough.sh
senke 05b1d81d30 fix(scripts): payment-e2e walkthrough safety guards (DRY_RUN + prod confirm)
Three holes in the v1.0.9 W6 Day 27 walkthrough that an operator under
stress could fall into :

1. Typo'd STAGING_URL pointing at production. The script accepted any
   URL with no sanity check, so `STAGING_URL=https://veza.fr ...` would
   happily POST /orders and charge a real card on the first run.
   Fix: heuristic detection (URL doesn't contain "staging", "localhost"
   or "127.0.0.1" → treat as prod) refuses to run unless
   CONFIRM_PRODUCTION=1 is explicitly set.

2. No way to rehearse the flow without spending money. Added DRY_RUN=1
   that exits cleanly after step 2 (product listing) — exercises auth,
   API plumbing, and the staging product fixture without creating an
   order.

3. No final confirm before the actual charge. On a prod target, after
   the product is picked and before the POST /orders fires, the script
   now prints the {product_id, price, operator, endpoint} block and
   demands the operator type the literal word `CHARGE`. Any other
   answer aborts with exit code 2.

Together these turn "STAGING_URL typo = burnt 5 EUR" into "STAGING_URL
typo = exit code 3 with explanation". The wrapper docs in
docs/PAYMENT_E2E_LIVE_REPORT.md already mention card-charge risk in
prose; these guards enforce it at exec time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 22:27:14 +02:00

370 lines
15 KiB
Bash
Executable file

#!/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-<timestamp>.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}
# v1.0.10 polish safety guards:
# DRY_RUN=1 — skip the POST /orders + payment steps; rehearse
# the login + product-listing + license-poll path
# end-to-end on staging without spending a euro.
# CONFIRM_PRODUCTION=1 — required when STAGING_URL points at the live
# environment. Without it the script refuses to
# run, so a typo (`STAGING_URL=https://veza.fr`
# on a sandbox-targeted command) can't accidentally
# charge a real card.
DRY_RUN=${DRY_RUN:-0}
CONFIRM_PRODUCTION=${CONFIRM_PRODUCTION:-0}
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
# Heuristic: any URL that doesn't include the substring "staging" is
# treated as production. Operators on a non-veza-domain (custom env)
# can still run the script; they just have to pass CONFIRM_PRODUCTION=1.
TARGET_LOOKS_LIKE_PROD=0
if [[ ! "$STAGING_URL" =~ staging ]] && [[ ! "$STAGING_URL" =~ localhost ]] && [[ ! "$STAGING_URL" =~ 127\.0\.0\.1 ]]; then
TARGET_LOOKS_LIKE_PROD=1
fi
if [ "$TARGET_LOOKS_LIKE_PROD" = "1" ] && [ "$CONFIRM_PRODUCTION" != "1" ]; then
cat >&2 <<EOF
================================================================
ABORTING — production target detected without explicit confirmation
================================================================
STAGING_URL=$STAGING_URL does not contain "staging", "localhost" or
"127.0.0.1", so this script will refuse to run by default to prevent
an accidental real-card charge.
If you genuinely want to run against production, re-invoke with:
CONFIRM_PRODUCTION=1 \\
STAGING_URL=$STAGING_URL \\
OPERATOR_EMAIL=$OPERATOR_EMAIL \\
OPERATOR_PASSWORD=... \\
bash scripts/payment-e2e-walkthrough.sh
Or set DRY_RUN=1 to rehearse the flow without making the actual charge.
================================================================
EOF
exit 3
fi
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN=1 — order creation + payment + refund steps will be SKIPPED"
fi
# 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.
# --------------------------------------------------------------------
if [ "$DRY_RUN" = "1" ]; then
log ""
log "step 3 : POST /api/v1/marketplace/orders — SKIPPED (dry-run)"
log "================================================================"
log "DRY-RUN PASS : login + product list + license-mine endpoints reached"
log "Run without DRY_RUN to exercise the real charge + refund flow."
log "================================================================"
exit 0
fi
log ""
log "step 3 : POST /api/v1/marketplace/orders"
# v1.0.10 polish: confirm prompt before the actual charge so a typo'd
# product_id or wrong operator account can't quietly burn 5 EUR.
if [ "$TARGET_LOOKS_LIKE_PROD" = "1" ]; then
log ""
log "================================================================"
log "FINAL CONFIRMATION — about to charge a real card on production"
log "================================================================"
log " product_id : $PRODUCT_ID"
log " price : $PRODUCT_PRICE"
log " operator : $OPERATOR_EMAIL"
log " endpoint : ${STAGING_URL}/api/v1/marketplace/orders"
log ""
prompt "Type the literal word 'CHARGE' to proceed (anything else aborts) :"
read -r confirm_word
if [ "$confirm_word" != "CHARGE" ]; then
fail "operator did not confirm the charge ($confirm_word) — aborting" 2
fi
log " operator confirmed CHARGE — proceeding"
fi
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:-<none — operator must navigate via UI>}"
# --------------------------------------------------------------------
# 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