veza/veza-backend-api/.env.template
senke d2bb9c0e78 feat(marketplace): async stripe connect reversal worker — v1.0.7 item B day 2
Day-2 cut of item B: the reversal path becomes async. Pre-v1.0.7
(and v1.0.7 day 1) the refund handler flipped seller_transfers
straight from completed to reversed without ever calling Stripe —
the ledger said "reversed" while the seller's Stripe balance still
showed the original transfer as settled. The new flow:

  refund.succeeded webhook
    → reverseSellerAccounting transitions row: completed → reversal_pending
    → StripeReversalWorker (every REVERSAL_CHECK_INTERVAL, default 1m)
      → calls ReverseTransfer on Stripe
      → success: row → reversed + persist stripe_reversal_id
      → 404 already-reversed (dead code until day 3): row → reversed + log
      → 404 resource_missing (dead code until day 3): row → permanently_failed
      → transient error: stay reversal_pending, bump retry_count,
        exponential backoff (base * 2^retry, capped at backoffMax)
      → retries exhausted: row → permanently_failed
    → buyer-facing refund completes immediately regardless of Stripe health

State machine enforcement:
  * New `SellerTransfer.TransitionStatus(tx, to, extras)` wraps every
    mutation: validates against AllowedTransferTransitions, guarded
    UPDATE with WHERE status=<from> (optimistic lock semantics), no
    RowsAffected = stale state / concurrent winner detected.
  * processSellerTransfers no longer mutates .Status in place —
    terminal status is decided before struct construction, so the
    row is Created with its final state.
  * transfer_retry.retryOne and admin RetryTransfer route through
    TransitionStatus. Legacy direct assignment removed.
  * TestNoDirectTransferStatusMutation greps the package for any
    `st.Status = "..."` / `t.Status = "..."` / GORM
    Model(&SellerTransfer{}).Update("status"...) outside the
    allowlist and fails if found. Verified by temporarily injecting
    a violation during development — test caught it as expected.

Configuration (v1.0.7 item B):
  * REVERSAL_WORKER_ENABLED=true (default)
  * REVERSAL_MAX_RETRIES=5 (default)
  * REVERSAL_CHECK_INTERVAL=1m (default)
  * REVERSAL_BACKOFF_BASE=1m (default)
  * REVERSAL_BACKOFF_MAX=1h (default, caps exponential growth)
  * .env.template documents TRANSFER_RETRY_* and REVERSAL_* env vars
    so an ops reader can grep them.

Interface change: TransferService.ReverseTransfer(ctx,
stripe_transfer_id, amount *int64, reason) (reversalID, error)
added. All four mocks extended (process_webhook, transfer_retry,
admin_transfer_handler, payment_flow integration). amount=nil means
full reversal; v1.0.7 always passes nil (partial reversal is future
scope per axis-1 P2).

Stripe 404 disambiguation (ErrTransferAlreadyReversed /
ErrTransferNotFound) is wired in the worker as dead code — the
sentinels are declared and the worker branches on them, but
StripeConnectService.ReverseTransfer doesn't yet emit them. Day 3
will parse stripe.Error.Code and populate the sentinels; no worker
change needed at that point. Keeping the handling skeleton in day 2
so the worker's branch shape doesn't change between days and the
tests can already cover all four paths against the mock.

Worker unit tests (9 cases, all green, sqlite :memory:):
  * happy path: reversal_pending → reversed + stripe_reversal_id set
  * already reversed (mock returns sentinel): → reversed + log
  * not found (mock returns sentinel): → permanently_failed + log
  * transient 503: retry_count++, next_retry_at set with backoff,
    stays reversal_pending
  * backoff capped at backoffMax (verified with base=1s, max=10s,
    retry_count=4 → capped at 10s not 16s)
  * max retries exhausted: → permanently_failed
  * legacy row with empty stripe_transfer_id: → permanently_failed,
    does not call Stripe
  * only picks up reversal_pending (skips all other statuses)
  * respects next_retry_at (future rows skipped)

Existing test updated: TestProcessRefundWebhook_SucceededFinalizesState
now asserts the row lands at reversal_pending with next_retry_at
set (worker's responsibility to drive to reversed), not reversed.

Worker wired in cmd/api/main.go alongside TransferRetryWorker,
sharing the same StripeConnectService instance. Shutdown path
registered for graceful stop.

Cut from day 2 scope (per agreed-upon discipline), landing in day 3:
  * Stripe 404 disambiguation implementation (parse error.Code)
  * End-to-end smoke probe (refund → reversal_pending → worker
    processes → reversed) against local Postgres + mock Stripe
  * Batch-size tuning / inter-batch sleep — batchLimit=20 today is
    safely under Stripe's 100 req/s default rate limit; revisit if
    observed load warrants

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:34:29 +02:00

172 lines
6 KiB
Text

# =============================================================================
# VEZA BACKEND API - ENVIRONMENT TEMPLATE
# =============================================================================
# This is a template file. Copy to .env and fill in actual values.
# DO NOT commit .env with real secrets to Git!
# =============================================================================
# --- ENVIRONMENT ---
# Options: development, staging, production
APP_ENV=development
APP_PORT=8080
LOG_LEVEL=info
# --- DOMAIN (single source of truth) ---
# All service URLs and CORS origins derive from this in development.
# Change this + /etc/hosts to switch domain.
APP_DOMAIN=veza.fr
# --- DATABASE (REQUIRED) ---
# PostgreSQL connection string (host ports when using docker-compose: 15432)
# In Docker: postgres:5432 | On host: veza.fr:15432
DATABASE_URL=postgres://veza:password@veza.fr:15432/veza?sslmode=disable
# Optional: Read replica for scaling read-heavy workloads (same format as DATABASE_URL)
# DATABASE_READ_URL=postgres://veza:password@veza-read-replica:5432/veza?sslmode=disable
DATABASE_MAX_OPEN_CONNS=25
DATABASE_MAX_IDLE_CONNS=5
DATABASE_CONN_MAX_LIFETIME=5m
# --- JWT & AUTHENTICATION (v0.9.1 RS256) ---
# PREFERRED: RS256 with RSA keys (generate with scripts/generate-jwt-keys.sh)
# JWT_PRIVATE_KEY_PATH=/path/to/jwt-private.pem
# JWT_PUBLIC_KEY_PATH=/path/to/jwt-public.pem
# FALLBACK (dev only): JWT_SECRET must be at least 32 characters
JWT_SECRET=dev-secret-key-minimum-32-characters-long-for-testing-only
JWT_ISSUER=veza-api
JWT_AUDIENCE=veza-platform
JWT_ACCESS_TOKEN_DURATION=15m
JWT_REFRESH_TOKEN_DURATION=30d
# --- COOKIES ---
# Set to true in production for HTTPS-only cookies
COOKIE_SECURE=false
COOKIE_SAME_SITE=lax
COOKIE_DOMAIN=
# --- CORS (REQUIRED) ---
# Comma-separated list of allowed origins
# Development: http://veza.fr:5173,http://veza.fr:3000 (or your APP_DOMAIN)
# Production: https://app.veza.com,https://www.veza.com
CORS_ALLOWED_ORIGINS=http://veza.fr:5173,http://veza.fr:3000
# --- REDIS (REQUIRED for CSRF, rate limiting, cache) ---
# Redis (host port when using docker-compose: 16379)
# In Docker: redis:6379 | On host: veza.fr:16379
REDIS_URL=redis://veza.fr:16379
REDIS_ADDR=veza.fr:6379
REDIS_PASSWORD=
REDIS_DB=0
# --- RABBITMQ ---
# Enable message queue for async events (use veza:password, host port 15672 for docker-compose)
# In Docker: amqp://veza:password@rabbitmq:5672/ | On host: amqp://veza:password@veza.fr:15672/
RABBITMQ_ENABLE=true
RABBITMQ_URL=amqp://veza:password@veza.fr:15672/
# --- SENTRY (OPTIONAL - Recommended for production) ---
# Error tracking and monitoring
SENTRY_DSN=
SENTRY_ENVIRONMENT=development
SENTRY_SAMPLE_RATE_ERRORS=1.0
SENTRY_SAMPLE_RATE_TRANSACTIONS=0.1
# --- RATE LIMITING ---
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_SECOND=100
# --- FILE UPLOADS ---
UPLOAD_DIR=./uploads
ENABLE_CLAMAV=false
CLAMAV_REQUIRED=false
# --- HYPERSWITCH (PAYMENTS - OPTIONAL) ---
# Required for real payment processing. Leave empty to use simulated payments.
HYPERSWITCH_ENABLED=false
HYPERSWITCH_URL=http://veza.fr:18081
# From Hyperswitch Control Center (app.hyperswitch.io) > Settings > Developers
HYPERSWITCH_API_KEY=
# For webhook signature verification
HYPERSWITCH_WEBHOOK_SECRET=
# Checkout success redirect (used in return_url)
CHECKOUT_SUCCESS_URL=http://veza.fr:5173/purchases
# --- STRIPE CONNECT (SELLER PAYOUT - OPTIONAL) ---
# Required for seller payout (balance, onboarding, transfers).
# Get keys from Stripe Dashboard > Connect > Settings
STRIPE_CONNECT_ENABLED=false
# Secret key for server-side Stripe API calls (sk_test_xxx or sk_live_xxx)
STRIPE_SECRET_KEY=
# Webhook secret for Connect events (whsec_xxx)
STRIPE_CONNECT_WEBHOOK_SECRET=
# --- TRANSFER RETRY WORKER (v0.701) ---
# Drives failed seller_transfers back to completed when Stripe recovers.
# TRANSFER_RETRY_ENABLED=true
# TRANSFER_RETRY_MAX=3
# TRANSFER_RETRY_INTERVAL=5m
# --- REVERSAL WORKER (v1.0.7 item B) ---
# Drives reversal_pending seller_transfers to reversed by calling Stripe
# Connect Transfers:reversal. Decouples buyer-facing refund UX from
# Stripe-side settlement health. Backoff is exponential (base * 2^retry),
# capped at BACKOFF_MAX.
# REVERSAL_WORKER_ENABLED=true
# REVERSAL_MAX_RETRIES=5
# REVERSAL_CHECK_INTERVAL=1m
# REVERSAL_BACKOFF_BASE=1m
# REVERSAL_BACKOFF_MAX=1h
# --- EXTERNAL SERVICES (OPTIONAL) ---
STREAM_SERVER_URL=http://veza.fr:8082
# Must match stream server INTERNAL_API_KEY for /internal/jobs/transcode (P1.1.2)
STREAM_SERVER_INTERNAL_API_KEY=
CHAT_SERVER_URL=http://veza.fr:8081
# --- FRONTEND URL ---
# Used for password reset links, email templates, etc.
FRONTEND_URL=http://veza.fr:5173
# --- BASE URL ---
# Public base URL of this backend (used for OAuth callbacks, etc.)
BASE_URL=http://veza.fr:8080
# --- EMAIL (REQUIRED for registration + password reset) ---
# Local dev: MailHog ships with `make infra-up-dev` — the defaults below
# point at it on localhost:1025, with the web UI at http://localhost:8025
# to inspect captured emails.
# Production: override with your SMTP provider (SendGrid, AWS SES, etc.).
#
# v1.0.6: unified on canonical SMTP_* names. The legacy SMTP_USER /
# FROM_EMAIL / FROM_NAME are still accepted as a deprecation fallback
# (backend logs a warning on use; removed in v1.1.0).
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=no-reply@veza.local
SMTP_FROM_NAME=Veza (dev)
# --- MONITORING (OPTIONAL) ---
PROMETHEUS_URL=
# =============================================================================
# VALIDATION RULES
# =============================================================================
#
# REQUIRED (app will not start without these):
# - DATABASE_URL
# - JWT_SECRET (min 32 chars)
# - REDIS_URL or REDIS_ADDR
# - CORS_ALLOWED_ORIGINS (can be empty for strict mode)
#
# RECOMMENDED for production:
# - SENTRY_DSN
# - COOKIE_SECURE=true
# - COOKIE_SAME_SITE=strict
#
# OPTIONAL:
# - RABBITMQ_* (if async events not used)
# - SMTP_* (if email not used)
# - CLAMAV_* (if file scanning not used)
#
# =============================================================================