#!/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} # 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 <&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:-}" # -------------------------------------------------------------------- # 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