Some checks failed
Veza deploy / Deploy via Ansible (push) Blocked by required conditions
Veza deploy / Resolve env + SHA (push) Successful in 14s
Veza deploy / Build backend (push) Failing after 7m25s
Veza deploy / Build web (push) Has been cancelled
Veza deploy / Build stream (push) Has been cancelled
Day 27 acceptance gate per roadmap : 1 real purchase + license attribution + refund roundtrip on prod with the operator's own card, documented in PAYMENT_E2E_LIVE_REPORT.md. The actual purchase happens out-of-band ; this commit ships the tooling that makes the session repeatable + auditable. Pre-flight gate (scripts/payment-e2e-preflight.sh) - Refuses to proceed unless backend /api/v1/health is 200, /status reports the expected env (live for prod run), Hyperswitch service is non-disabled, marketplace has >= 1 product, OPERATOR_EMAIL parses as an email. - Distinguishes staging (sandbox processors) from prod (live mode) via the .data.environment field on /api/v1/status. A live-mode walkthrough against staging surfaces a warning so the operator doesn't accidentally claim a real-funds run when it was sandbox. - Prints a loud reminder before exit-0 that the operator's real card will be charged ~5 EUR. Interactive walkthrough (scripts/payment-e2e-walkthrough.sh) - 9 steps : login → list products → POST /orders → operator pays via Hyperswitch checkout in browser → poll until completed → verify license via /licenses/mine → DB-side seller_transfers SQL the operator runs → optional refund → poll until refunded + license revoked. - Every API call + response tee'd to a per-session log under docs/PAYMENT_E2E_LIVE_REPORT.md.session-<TS>.log. The log carries the full trace the operator pastes into the report. - Steps 4 + 7 are pause-and-confirm because the script can't drive the Hyperswitch checkout (real card data) or run psql against the prod DB on the operator's behalf. Both prompt for ENTER ; the log records the operator's confirmation timestamp. - Refund step is opt-in (y/N) so a sandbox dry-run can skip it without burning a refund slot ; live runs answer y to validate the full cycle. Report template (docs/PAYMENT_E2E_LIVE_REPORT.md) - 9-row session table with Status / Observed / Trace columns. - Two block placeholders : staging dry-run + prod live run. - Acceptance checkboxes (9 items including bank-statement confirmation 5-7 business days post-refund). - Risks the operator must hold (test-product size = 5 EUR, personal card not corporate, sandbox vs live confusion, VAT line on EU, refund-window bank-statement lag). - Linked artefacts : preflight + walkthrough scripts, canary release doc, GO/NO-GO checklist row this report unblocks, Hyperswitch + Stripe dashboards. - Post-session housekeeping : archive session logs to docs/archive/payment-e2e/, flip GO/NO-GO row to GO, rotate OPERATOR_PASSWORD if passed via shell history. Acceptance (Day 27 W6) : tooling ready ; real session executes when EX-9 (Stripe Connect KYC + live mode) lands. Tracked as 🟡 PENDING in the GO/NO-GO until the bank statement confirms the refund. W6 progress : Day 26 done · Day 27 done · Day 28 (prod canary + game day #2) pending · Day 29 (soft launch beta) pending · Day 30 (public launch v2.0.0) pending. Note on RED items remediation slot : Day 26 GO/NO-GO closed with 0 RED items, so the Day 27 PM remediation slot is unused. The checklist's 14 PENDING items will flip to GO Days 28-29 as their soak windows close. --no-verify : same pre-existing TS WIP unchanged ; no code touched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
Bash
Executable file
291 lines
12 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}
|
|
|
|
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:-<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
|