diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 204c8ec3..d4e09587 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,7 +16,7 @@ concurrency: jobs: quality-and-build: - name: Backend Quality & Build + name: Quality & Build runs-on: ubuntu-latest env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_ci_placeholder?schema=public @@ -25,11 +25,9 @@ jobs: working-directory: ./fluxapay_backend steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -44,20 +42,20 @@ jobs: - name: Generate Prisma Client run: npx prisma generate - - name: Format Check (Lint) + - name: Lint run: npm run lint - - name: Validate OpenAPI Specification + - name: Validate OpenAPI spec run: npm run validate:openapi - - name: Check Route Documentation Coverage + - name: Check route documentation coverage run: npm run check:route-coverage -- --ci - - name: Build Check + - name: Build run: npm run build unit-and-contract-tests: - name: Backend Unit & Contract Tests + name: Unit, Contract & Smoke Tests runs-on: ubuntu-latest env: NODE_ENV: test @@ -97,11 +95,9 @@ jobs: working-directory: ./fluxapay_backend steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -127,11 +123,19 @@ jobs: - name: Sync database schema run: npx prisma db push - - name: Run Unit Tests + - name: Run unit tests run: npm test - - name: Run Contract Tests + - name: Run contract tests run: npm run test:contract env: - # Contract suite runs after the full unit suite; avoid merchant create rate limit. PAYMENT_RATE_LIMIT_PER_MINUTE: "10000" + + - name: Build backend & run smoke tests + run: | + npm run build + npm start & + npx --yes wait-on@9.0.4 http://localhost:3000/health --timeout 120000 --interval 2000 + npm run test:smoke + env: + API_BASE_URL: http://localhost:3000 diff --git a/.github/workflows/canary-deploy.yml b/.github/workflows/canary-deploy.yml deleted file mode 100644 index bebc9efc..00000000 --- a/.github/workflows/canary-deploy.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: Canary Deployment - -on: - push: - branches: [main] - -concurrency: - group: canary-deploy-${{ github.ref }} - cancel-in-progress: true - -jobs: - # ── Stage 1: Deploy to staging ────────────────────────────────────────────── - deploy-staging: - name: Deploy to Staging - runs-on: ubuntu-latest - environment: staging - outputs: - staging_url: ${{ steps.set-url.outputs.staging_url }} - - steps: - - uses: actions/checkout@v4 - - - name: Set staging URL - id: set-url - run: echo "staging_url=${{ vars.STAGING_URL || 'http://staging.fluxapay.internal' }}" >> "$GITHUB_OUTPUT" - - - name: Build backend image - working-directory: ./fluxapay_backend - run: docker build -f Dockerfile -t fluxapay-backend:${{ github.sha }} . - - - name: Deploy to staging - run: | - echo "Deploying fluxapay-backend:${{ github.sha }} to staging" - # Replace with your actual deploy command, e.g.: - # kubectl set image deployment/fluxapay-backend app=fluxapay-backend:${{ github.sha }} --namespace=staging - # or: ssh deploy@staging "docker pull ... && docker-compose up -d" - echo "DEPLOY_SHA=${{ github.sha }}" >> $GITHUB_ENV - - # ── Stage 2: Integration tests against staging ────────────────────────────── - integration-tests-staging: - name: Integration Tests (Staging) - runs-on: ubuntu-latest - needs: deploy-staging - environment: staging - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: fluxapay_staging_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 5 - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: npm - cache-dependency-path: fluxapay_backend/package-lock.json - - - name: Install dependencies - working-directory: ./fluxapay_backend - run: npm ci - - - name: Generate Prisma Client - working-directory: ./fluxapay_backend - run: npx prisma generate - - - name: Sync database schema - working-directory: ./fluxapay_backend - run: npx prisma db push - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_staging_test?schema=public - - - name: Run unit + contract tests - working-directory: ./fluxapay_backend - run: npm test && npm run test:contract - env: - NODE_ENV: test - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_staging_test?schema=public - JWT_SECRET: staging-ci-jwt-secret - KMS_PROVIDER: local - KMS_ENCRYPTION_PASSPHRASE: staging-ci-kms-passphrase - HD_WALLET_MASTER_SEED: staging-ci-master-seed-32chars-padded!! - DISABLE_CRON: "true" - WEBHOOK_SECRET: staging-ci-webhook-secret - CLOUDINARY_CLOUD_NAME: test - CLOUDINARY_API_KEY: test - CLOUDINARY_API_SECRET: test - RESEND_API_KEY: re_test - SMS_PROVIDER: mock - STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org - STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - FUNDER_SECRET_KEY: SBXEHJKWF7C7BIFSUGXZE7XKFJ5SP6Y7JGPD4J4TKMX2MQXWQQRF3BSG - MASTER_VAULT_SECRET_KEY: SDGHCAPDRWTOTBAQT6HJ5KBUZOECD2JEOH6E76NDL4OXNBXIWODLZA4B - USDC_ISSUER_PUBLIC_KEY: GBVXGET7UCLTTLDZGVWDDMZ7FVLCNZL75GU7BLVPTWUP6UIVYHAY5OAX - PAYMENT_RATE_LIMIT_PER_MINUTE: "10000" - - - name: Build & start backend (smoke) - working-directory: ./fluxapay_backend - run: | - npm run build - npm start & - npx --yes wait-on@9.0.4 http://localhost:3000/health --timeout 60000 --interval 2000 - env: - NODE_ENV: test - PORT: 3000 - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_staging_test?schema=public - JWT_SECRET: staging-ci-jwt-secret - KMS_PROVIDER: local - KMS_ENCRYPTION_PASSPHRASE: staging-ci-kms-passphrase - HD_WALLET_MASTER_SEED: staging-ci-master-seed-32chars-padded!! - DISABLE_CRON: "true" - WEBHOOK_SECRET: staging-ci-webhook-secret - CLOUDINARY_CLOUD_NAME: test - CLOUDINARY_API_KEY: test - CLOUDINARY_API_SECRET: test - RESEND_API_KEY: re_test - SMS_PROVIDER: mock - STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org - STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - FUNDER_SECRET_KEY: SBXEHJKWF7C7BIFSUGXZE7XKFJ5SP6Y7JGPD4J4TKMX2MQXWQQRF3BSG - MASTER_VAULT_SECRET_KEY: SDGHCAPDRWTOTBAQT6HJ5KBUZOECD2JEOH6E76NDL4OXNBXIWODLZA4B - USDC_ISSUER_PUBLIC_KEY: GBVXGET7UCLTTLDZGVWDDMZ7FVLCNZL75GU7BLVPTWUP6UIVYHAY5OAX - - - name: Run API smoke tests against staging - working-directory: ./fluxapay_backend - run: npm run test:smoke - env: - API_BASE_URL: http://localhost:3000 - - # ── Stage 3: Promote to production (only if staging passed) ───────────────── - deploy-production: - name: Deploy to Production - runs-on: ubuntu-latest - needs: integration-tests-staging - environment: production - - steps: - - uses: actions/checkout@v4 - - - name: Deploy to production - run: | - echo "Promoting fluxapay-backend:${{ github.sha }} to production" - # Replace with your actual production deploy command, e.g.: - # kubectl set image deployment/fluxapay-backend app=fluxapay-backend:${{ github.sha }} --namespace=production - - # ── Rollback: triggered automatically if staging tests fail ───────────────── - rollback-staging: - name: Rollback Staging - runs-on: ubuntu-latest - needs: integration-tests-staging - if: failure() - environment: staging - - steps: - - uses: actions/checkout@v4 - - - name: Rollback staging to previous image - run: | - echo "Staging tests failed — rolling back staging to previous stable image" - # Replace with your actual rollback command, e.g.: - # kubectl rollout undo deployment/fluxapay-backend --namespace=staging - echo "Rollback complete. Production was NOT updated." diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index 5b75f2cf..00000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,274 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: ["main"] - paths: - - "fluxapay_backend/**" - pull_request: - branches: ["main"] - paths: - - "fluxapay_backend/**" - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}/fluxapay-backend - -jobs: - quality-and-build: - name: Backend Quality & Build - runs-on: ubuntu-latest - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_ci_placeholder?schema=public - defaults: - run: - working-directory: ./fluxapay_backend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_backend/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Validate Prisma schema - run: npx prisma validate - - - name: Generate Prisma Client - run: npx prisma generate - - - name: Format Check (Lint) - run: npm run lint - - - name: Validate OpenAPI Specification - run: npm run validate:openapi - - - name: Check Route Documentation Coverage - run: npm run check:route-coverage -- --ci - - - name: Build Check - run: npm run build - - unit-and-contract-tests: - name: Backend Unit & Contract Tests - runs-on: ubuntu-latest - env: - NODE_ENV: test - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_test?schema=public - JWT_SECRET: ci-test-jwt-secret-key - KMS_PROVIDER: local - KMS_ENCRYPTION_PASSPHRASE: ci-test-passphrase-for-hd-wallet-secure - HD_WALLET_MASTER_SEED: ci-test-master-seed-32chars-long-padded!! - DISABLE_CRON: "true" - WEBHOOK_SECRET: ci-test-webhook-secret - CLOUDINARY_CLOUD_NAME: test - CLOUDINARY_API_KEY: test - CLOUDINARY_API_SECRET: test - RESEND_API_KEY: re_test - SMS_PROVIDER: mock - STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org - STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - FUNDER_SECRET_KEY: SBXEHJKWF7C7BIFSUGXZE7XKFJ5SP6Y7JGPD4J4TKMX2MQXWQQRF3BSG - MASTER_VAULT_SECRET_KEY: SDGHCAPDRWTOTBAQT6HJ5KBUZOECD2JEOH6E76NDL4OXNBXIWODLZA4B - USDC_ISSUER_PUBLIC_KEY: GBVXGET7UCLTTLDZGVWDDMZ7FVLCNZL75GU7BLVPTWUP6UIVYHAY5OAX - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: fluxapay_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 5 - defaults: - run: - working-directory: ./fluxapay_backend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_backend/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Validate Prisma schema - run: npx prisma validate - - - name: Check migrations are in sync with schema - run: | - npx prisma migrate diff \ - --from-migrations prisma/migrations \ - --to-schema-datamodel prisma/schema.prisma \ - --shadow-database-url "$DATABASE_URL" \ - --exit-code - - - name: Generate Prisma Client - run: npx prisma generate - - - name: Sync database schema - run: npx prisma db push - - - name: Run Unit Tests - run: npm test - - - name: Run Contract Tests - run: npm run test:contract - env: - PAYMENT_RATE_LIMIT_PER_MINUTE: "10000" - - build-and-push-docker: - name: Build and Push Docker Image - runs-on: ubuntu-latest - needs: [quality-and-build, unit-and-contract-tests] - if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' - permissions: - contents: read - packages: write - outputs: - image-tag: ${{ steps.meta.outputs.tags }} - image-digest: ${{ steps.build.outputs.digest }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=sha,prefix=sha- - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - id: build - uses: docker/build-push-action@v5 - with: - context: ./fluxapay_backend - push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Generate image digest - id: digest - run: echo "digest=${{ steps.build.outputs.digest }}" >> $GITHUB_OUTPUT - - deploy-staging: - name: Deploy to Staging - runs-on: ubuntu-latest - needs: build-and-push-docker - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false - environment: - name: staging - url: https://staging.fluxapay.com - permissions: - pull-requests: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Deploy to staging - run: | - echo "Deploying to staging environment..." - echo "Image: ${{ needs.build-and-push-docker.outputs.image-tag }}" - # Add actual deployment commands here (e.g., kubectl apply, helm upgrade, etc.) - # Example: - # kubectl set image deployment/fluxapay-backend fluxapay-backend=${{ needs.build-and-push-docker.outputs.image-tag }} -n staging - # kubectl rollout status deployment/fluxapay-backend -n staging - - - name: Comment on PR with deployment info - uses: actions/github-script@v7 - with: - script: | - const imageTag = '${{ needs.build-and-push-docker.outputs.image-tag }}'; - const imageDigest = '${{ needs.build-and-push-docker.outputs.image-digest }}'; - const stagingUrl = 'https://staging.fluxapay.com'; - - const comment = `## 🚀 Staging Deployment Successful\n\n` + - `**Image Tag:** \`${imageTag}\`\n` + - `**Image Digest:** \`${imageDigest}\`\n` + - `**Staging URL:** ${stagingUrl}\n\n` + - `The staging environment has been updated with the latest changes from this PR.\n\n` + - `Please test the changes and provide feedback.`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); - - deploy-production: - name: Deploy to Production - runs-on: ubuntu-latest - needs: build-and-push-docker - if: github.ref == 'refs/heads/main' - environment: - name: production - url: https://api.fluxapay.com - permissions: - contents: read - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Deploy to production - run: | - echo "Deploying to production environment..." - echo "Image: ${{ needs.build-and-push-docker.outputs.image-tag }}" - # Add actual deployment commands here (e.g., kubectl apply, helm upgrade, etc.) - # Example: - # kubectl set image deployment/fluxapay-backend fluxapay-backend=${{ needs.build-and-push-docker.outputs.image-tag }} -n production - # kubectl rollout status deployment/fluxapay-backend -n production - - - name: Notify on deployment success - if: success() - run: | - echo "Production deployment successful!" - # Add notification logic here (e.g., Slack, Discord, email, etc.) - - - name: Notify on deployment failure - if: failure() - run: | - echo "Production deployment failed!" - # Add notification logic here (e.g., Slack, Discord, email, etc.) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 1c09aea7..fc66e572 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -2,11 +2,11 @@ name: Frontend CI on: push: - branches: ["main", "frontend"] + branches: ["main"] paths: - "fluxapay_frontend/**" pull_request: - branches: ["main", "frontend"] + branches: ["main"] paths: - "fluxapay_frontend/**" @@ -15,19 +15,17 @@ concurrency: cancel-in-progress: true jobs: - lint: - name: Frontend Lint + lint-and-build: + name: Lint & Build runs-on: ubuntu-latest defaults: run: working-directory: ./fluxapay_frontend steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -36,32 +34,10 @@ jobs: - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Format Check (Lint) + - name: Lint run: npx eslint . --max-warnings=0 - continue-on-error: true - build: - name: Frontend Build Check - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./fluxapay_frontend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_frontend/package-lock.json - - - name: Install dependencies - run: npm ci --legacy-peer-deps - - - name: Build Check + - name: Build run: npm run build unit-tests: @@ -72,11 +48,9 @@ jobs: working-directory: ./fluxapay_frontend steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -89,23 +63,17 @@ jobs: run: npm test e2e-tests: - name: E2E (${{ matrix.e2e_mode }}) + name: E2E Tests (mocked) runs-on: ubuntu-latest - needs: [build] - strategy: - fail-fast: false - matrix: - e2e_mode: [mocked, real] + needs: [lint-and-build] defaults: run: working-directory: ./fluxapay_frontend steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -114,37 +82,27 @@ jobs: - name: Install dependencies run: npm ci --legacy-peer-deps - - name: Start backend stack (real mode) - if: matrix.e2e_mode == 'real' - working-directory: ${{ github.workspace }} - env: - E2E_ACCEPT_OTP: ${{ secrets.E2E_TEST_OTP }} - run: docker compose -f docker-compose.ci.yml up -d --build postgres backend - - - name: Wait for backend health (real mode) - if: matrix.e2e_mode == 'real' - run: npx wait-on http://127.0.0.1:3000/health --timeout 180000 --interval 3000 - - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Build Next.js app run: npm run build - env: - NEXT_PUBLIC_API_URL: ${{ matrix.e2e_mode == 'real' && 'http://127.0.0.1:3000' || 'http://127.0.0.1:3001' }} - name: Start app and run E2E tests - continue-on-error: ${{ matrix.e2e_mode == 'real' }} run: | npm start & npx wait-on http://localhost:3075 --timeout 120000 --interval 2000 npm run test:e2e env: CI: true - E2E_MODE: ${{ matrix.e2e_mode }} + E2E_MODE: mocked PLAYWRIGHT_BASE_URL: http://localhost:3075 E2E_BASE_URL: http://localhost:3075 - E2E_API_URL: ${{ matrix.e2e_mode == 'real' && 'http://127.0.0.1:3000' || 'http://127.0.0.1:3001' }} - E2E_TEST_EMAIL: ${{ matrix.e2e_mode == 'real' && secrets.E2E_TEST_EMAIL || '' }} - E2E_TEST_PASSWORD: ${{ matrix.e2e_mode == 'real' && secrets.E2E_TEST_PASSWORD || '' }} - E2E_TEST_OTP: ${{ matrix.e2e_mode == 'real' && secrets.E2E_TEST_OTP || '' }} + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: fluxapay_frontend/playwright-report/ + retention-days: 7 diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml deleted file mode 100644 index 4a75533c..00000000 --- a/.github/workflows/integration-smoke.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Integration Smoke Test - -on: - push: - branches: ["main"] - pull_request: - branches: ["main", "feature/*", "ci/*"] - -jobs: - smoke-test: - name: Docker Integration Smoke Test - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Bring up services - run: docker compose -f docker-compose.ci.yml up -d --build - - - name: Wait for Backend to be healthy - run: | - echo "Waiting for backend to be ready..." - # The healthcheck is already defined in docker-compose.ci.yml - # We'll wait for it manually if needed, but 'docker compose up -d' - # doesn't wait for health by default. - for i in {1..60}; do - HEALTH_STATUS=$(docker inspect --format='{{json .State.Health.Status}}' fluxapay-ci-backend 2>/dev/null || echo '"starting"') - if [ "$HEALTH_STATUS" == '"healthy"' ]; then - echo "Backend is healthy!" - exit 0 - fi - echo "Status: $HEALTH_STATUS... ($i/60)" - sleep 5 - done - echo "Backend failed to become healthy" - docker compose -f docker-compose.ci.yml logs - exit 1 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_backend/package-lock.json - - - name: Run API smoke tests - working-directory: ./fluxapay_backend - run: | - npm ci || npm install - npm run test:smoke - env: - API_BASE_URL: http://localhost:3000 - - - name: Frontend build step (Optional) - working-directory: ./fluxapay_frontend - run: | - npm ci --legacy-peer-deps || npm install --legacy-peer-deps - npm run build - env: - NEXT_PUBLIC_API_URL: http://localhost:3000 - - - name: Cleanup - if: always() - run: docker compose -f docker-compose.ci.yml down diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml deleted file mode 100644 index ec9ffe43..00000000 --- a/.github/workflows/integration-tests.yml +++ /dev/null @@ -1,314 +0,0 @@ -name: Integration Tests - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -# Cancel previous runs on the same PR/branch -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - backend-integration: - name: Backend Integration Tests - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: fluxapay_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 5s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_backend/package-lock.json - - - name: Install backend dependencies - working-directory: ./fluxapay_backend - run: npm ci - - - name: Generate Prisma Client - working-directory: ./fluxapay_backend - run: npx prisma generate - - - name: Sync database schema - working-directory: ./fluxapay_backend - run: npx prisma db push - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_test?schema=public - - - name: Run backend unit tests - working-directory: ./fluxapay_backend - run: npm test - env: - NODE_ENV: test - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_test?schema=public - KMS_PROVIDER: local - KMS_ENCRYPTION_PASSPHRASE: ci-test-passphrase-for-hd-wallet-secure - HD_WALLET_MASTER_SEED: ci-test-master-seed-32chars-long-padded!! - DISABLE_CRON: "true" - JWT_SECRET: ci-test-jwt-secret-key - WEBHOOK_SECRET: ci-test-webhook-secret - CLOUDINARY_CLOUD_NAME: test - CLOUDINARY_API_KEY: test - CLOUDINARY_API_SECRET: test - RESEND_API_KEY: re_test - SMS_PROVIDER: mock - STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org - STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - FUNDER_SECRET_KEY: SBXEHJKWF7C7BIFSUGXZE7XKFJ5SP6Y7JGPD4J4TKMX2MQXWQQRF3BSG - MASTER_VAULT_SECRET_KEY: SDGHCAPDRWTOTBAQT6HJ5KBUZOECD2JEOH6E76NDL4OXNBXIWODLZA4B - USDC_ISSUER_PUBLIC_KEY: GBVXGET7UCLTTLDZGVWDDMZ7FVLCNZL75GU7BLVPTWUP6UIVYHAY5OAX - - - name: Build backend - working-directory: ./fluxapay_backend - run: npm run build - - - name: Start backend server - working-directory: ./fluxapay_backend - run: | - npm start & - echo "Waiting for backend to be ready..." - npx --yes wait-on@9.0.4 http://localhost:3000/health --timeout 120000 --interval 2000 - env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/fluxapay_test?schema=public - KMS_PROVIDER: local - KMS_ENCRYPTION_PASSPHRASE: ci-test-passphrase-for-hd-wallet-secure - HD_WALLET_MASTER_SEED: ci-test-master-seed-32chars-long-padded!! - DISABLE_CRON: "true" - JWT_SECRET: ci-test-jwt-secret-key - WEBHOOK_SECRET: ci-test-webhook-secret - CLOUDINARY_CLOUD_NAME: test - CLOUDINARY_API_KEY: test - CLOUDINARY_API_SECRET: test - RESEND_API_KEY: re_test - SMS_PROVIDER: mock - STELLAR_HORIZON_URL: https://horizon-testnet.stellar.org - STELLAR_NETWORK_PASSPHRASE: Test SDF Network ; September 2015 - FUNDER_SECRET_KEY: SBXEHJKWF7C7BIFSUGXZE7XKFJ5SP6Y7JGPD4J4TKMX2MQXWQQRF3BSG - MASTER_VAULT_SECRET_KEY: SDGHCAPDRWTOTBAQT6HJ5KBUZOECD2JEOH6E76NDL4OXNBXIWODLZA4B - USDC_ISSUER_PUBLIC_KEY: GBVXGET7UCLTTLDZGVWDDMZ7FVLCNZL75GU7BLVPTWUP6UIVYHAY5OAX - NODE_ENV: test - - - name: Run API smoke tests - working-directory: ./fluxapay_backend - run: npm run test:smoke - env: - API_BASE_URL: http://localhost:3000 - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: backend-test-results - path: | - fluxapay_backend/test-results/ - fluxapay_backend/coverage/ - retention-days: 7 - if-no-files-found: ignore - - frontend-integration: - name: Frontend Integration Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: fluxapay_frontend/package-lock.json - - - name: Install dependencies - working-directory: ./fluxapay_frontend - run: npm ci --legacy-peer-deps - - - name: Install Playwright browsers - working-directory: ./fluxapay_frontend - run: npx playwright install --with-deps chromium - - - name: Run linting - working-directory: ./fluxapay_frontend - run: npx eslint . --quiet - - - name: Build frontend - working-directory: ./fluxapay_frontend - run: npm run build - - - name: Run unit tests - working-directory: ./fluxapay_frontend - run: npm test - - - name: Start frontend and run E2E smoke tests - working-directory: ./fluxapay_frontend - run: | - npm start & - echo "Waiting for frontend to be ready..." - npx --yes wait-on@9.0.4 http://localhost:3075 --timeout 120000 --interval 2000 - echo "Running E2E smoke tests..." - npm run test:e2e:smoke - echo "Verifying new E2E spec files: invoices.spec.ts, payment-links.spec.ts" - npx playwright test e2e/invoices.spec.ts e2e/payment-links.spec.ts --config=playwright.smoke.config.ts - env: - PLAYWRIGHT_BASE_URL: http://localhost:3075 - NEXT_PUBLIC_API_URL: http://localhost:3000 - CI: true - - - name: Upload test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: frontend-test-results - path: | - fluxapay_frontend/test-results/ - fluxapay_frontend/coverage/ - retention-days: 7 - if-no-files-found: ignore - - - name: Upload Playwright report - if: always() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: fluxapay_frontend/playwright-report/ - retention-days: 7 - if-no-files-found: ignore - - route-drift-check: - name: Route Drift Detection - runs-on: ubuntu-latest - continue-on-error: true - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - - name: Install dependencies - working-directory: ./fluxapay_backend - run: npm ci || npm install - - - name: Install frontend dependencies - working-directory: ./fluxapay_frontend - run: npm ci --legacy-peer-deps || npm install --legacy-peer-deps - - - name: Run route drift detection - run: | - echo "🔍 Checking for route drift between frontend and backend..." - echo "" - - # Extract API routes from backend - echo "Extracting backend routes..." - if [ -d "fluxapay_backend/src/routes" ]; then - grep -rh "router\.\(get\|post\|put\|patch\|delete\)" fluxapay_backend/src/routes/ --include="*.ts" 2>/dev/null | \ - grep -oE "(get|post|put|patch|delete)\(['\"][^'\"]+['\"]" | \ - sed "s/.*(//;s/['\")].*//" | \ - sort -u > /tmp/backend_routes.txt || echo "" > /tmp/backend_routes.txt - else - echo "Backend routes directory not found" > /tmp/backend_routes.txt - fi - - # Extract API calls from frontend - echo "Extracting frontend API calls..." - if [ -d "fluxapay_frontend/src" ]; then - grep -rhE "(fetch|axios|api\.)" fluxapay_frontend/src/ --include="*.ts" --include="*.tsx" 2>/dev/null | \ - grep -oE "(GET|POST|PUT|PATCH|DELETE)['\"]?[^\"]*['\"][^'\"]*['\"]" | \ - sed "s/.*['\"(]//;s/['\")].*//" | \ - sort -u > /tmp/frontend_routes.txt || echo "" > /tmp/frontend_routes.txt - else - echo "Frontend src directory not found" > /tmp/frontend_routes.txt - fi - - # Compare routes - echo "" - echo "Backend routes:" - cat /tmp/backend_routes.txt 2>/dev/null || echo "(none)" - echo "" - echo "Frontend API calls:" - cat /tmp/frontend_routes.txt 2>/dev/null || echo "(none)" - echo "" - - # Save drift report - { - echo "# Route Drift Detection Report" - echo "Generated: $(date -u)" - echo "" - echo "## Backend Routes" - echo '```' - cat /tmp/backend_routes.txt 2>/dev/null || echo "(none)" - echo '```' - echo "" - echo "## Frontend API Calls" - echo '```' - cat /tmp/frontend_routes.txt 2>/dev/null || echo "(none)" - echo '```' - echo "" - echo "## Status" - echo "✅ Route drift check completed" - } > route-drift-report.md - - echo "✅ Route drift report saved to route-drift-report.md" - - - name: Upload drift report - uses: actions/upload-artifact@v4 - with: - name: route-drift-report - path: route-drift-report.md - retention-days: 7 - - integration-summary: - name: Integration Test Summary - runs-on: ubuntu-latest - needs: [backend-integration, frontend-integration, route-drift-check] - if: always() - - steps: - - name: Generate summary - run: | - echo "## Integration Test Summary" > $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Test Suite | Status |" >> $GITHUB_STEP_SUMMARY - echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY - - backend_status="${{ needs.backend-integration.result }}" - frontend_status="${{ needs.frontend-integration.result }}" - drift_status="${{ needs.route-drift-check.result }}" - - echo "| Backend Integration | $backend_status |" >> $GITHUB_STEP_SUMMARY - echo "| Frontend Integration | $frontend_status |" >> $GITHUB_STEP_SUMMARY - echo "| Route Drift Check | $drift_status |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [[ "$backend_status" == "success" && "$frontend_status" == "success" ]]; then - echo "✅ Core integration tests passed!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Note: Route drift check is informational and may not block deployment." >> $GITHUB_STEP_SUMMARY - else - echo "❌ Some integration tests failed. Check the logs above for details." >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/load-tests.yml b/.github/workflows/load-tests.yml deleted file mode 100644 index cd97bcbb..00000000 --- a/.github/workflows/load-tests.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Load Tests - -on: - # Run weekly so performance regressions are caught automatically. - schedule: - - cron: "0 2 * * 1" # every Monday at 02:00 UTC - - # Allow manual runs with tunable parameters (e.g. after a deploy or on demand). - workflow_dispatch: - inputs: - base_url: - description: "API base URL (must look like staging)" - required: false - default: "" - ramp_vus: - description: "VUs during ramp-up" - required: false - default: "5" - steady_vus: - description: "VUs during steady state" - required: false - default: "10" - ramp_up: - description: "Ramp-up duration (e.g. 30s, 1m)" - required: false - default: "30s" - steady_duration: - description: "Steady-state duration (e.g. 2m, 5m)" - required: false - default: "2m" - ramp_down: - description: "Ramp-down duration" - required: false - default: "30s" - -concurrency: - group: load-tests-${{ github.ref }} - cancel-in-progress: true - -jobs: - payment-create-list: - name: "k6 — Payment Create + List" - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Run k6 load test - uses: grafana/k6-action@v0.3.1 - with: - filename: fluxapay_backend/load-tests/k6-payment-create-list.js - flags: >- - --out json=k6-results.json - env: - BASE_URL: ${{ inputs.base_url || secrets.STAGING_BASE_URL }} - API_KEY: ${{ secrets.STAGING_API_KEY }} - RAMP_VUS: ${{ inputs.ramp_vus || '5' }} - STEADY_VUS: ${{ inputs.steady_vus || '10' }} - RAMP_UP: ${{ inputs.ramp_up || '30s' }} - STEADY_DURATION: ${{ inputs.steady_duration || '2m' }} - RAMP_DOWN: ${{ inputs.ramp_down || '30s' }} - REQUIRE_STAGING_GUARD: "true" - - - name: Upload k6 results - if: always() - uses: actions/upload-artifact@v4 - with: - name: k6-results-${{ github.run_id }} - path: k6-results.json - retention-days: 30 diff --git a/.github/workflows/sdk-release-python-go.yml b/.github/workflows/sdk-release-python-go.yml deleted file mode 100644 index 5a5e0a92..00000000 --- a/.github/workflows/sdk-release-python-go.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: SDK Release (Python & Go) - -on: - push: - tags: - - "python-sdk-v*.*.*" - - "go-sdk-v*.*.*" - -jobs: - # ── Python SDK → PyPI ──────────────────────────────────────────────────────── - publish-python-sdk: - name: Publish Python SDK to PyPI - runs-on: ubuntu-latest - if: startsWith(github.ref_name, 'python-sdk-v') - defaults: - run: - working-directory: ./fluxapay_python_sdk - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Validate tag matches package version - run: | - TAG_VERSION="${GITHUB_REF_NAME#python-sdk-v}" - PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then - echo "Tag $TAG_VERSION != pyproject.toml $PKG_VERSION" - exit 1 - fi - - - name: Install build deps - run: pip install build pytest pytest-asyncio respx httpx - - - name: Run tests - run: pytest tests/ -v - - - name: Build distribution - run: python -m build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: fluxapay_python_sdk/dist/ - password: ${{ secrets.PYPI_TOKEN }} - - # ── Go SDK → tag (go get compatible) ───────────────────────────────────────── - validate-go-sdk: - name: Validate Go SDK - runs-on: ubuntu-latest - if: startsWith(github.ref_name, 'go-sdk-v') - defaults: - run: - working-directory: ./fluxapay_go_sdk - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: "1.21" - - - name: Validate tag matches go.mod module path - run: | - TAG_VERSION="${GITHUB_REF_NAME#go-sdk-v}" - echo "Releasing Go SDK v$TAG_VERSION" - go vet ./... - - - name: Run tests - run: go test ./... -v - - - name: Build check - run: go build ./... diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml index 14cc0b1f..709c875a 100644 --- a/.github/workflows/sdk-release.yml +++ b/.github/workflows/sdk-release.yml @@ -4,11 +4,15 @@ on: push: tags: - "sdk-v*.*.*" + - "python-sdk-v*.*.*" + - "go-sdk-v*.*.*" jobs: - publish-sdk: - name: Publish @fluxapay/sdk + # ── JS/TS SDK → npm ───────────────────────────────────────────────────────── + publish-js-sdk: + name: Publish @fluxapay/sdk to npm runs-on: ubuntu-latest + if: startsWith(github.ref_name, 'sdk-v') defaults: run: working-directory: ./fluxapay_sdk @@ -48,3 +52,71 @@ jobs: run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # ── Python SDK → PyPI ──────────────────────────────────────────────────────── + publish-python-sdk: + name: Publish Python SDK to PyPI + runs-on: ubuntu-latest + if: startsWith(github.ref_name, 'python-sdk-v') + defaults: + run: + working-directory: ./fluxapay_python_sdk + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate tag matches package version + run: | + TAG_VERSION="${GITHUB_REF_NAME#python-sdk-v}" + PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag $TAG_VERSION != pyproject.toml $PKG_VERSION" + exit 1 + fi + + - name: Install build deps + run: pip install build pytest pytest-asyncio respx httpx + + - name: Run tests + run: pytest tests/ -v + + - name: Build distribution + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: fluxapay_python_sdk/dist/ + password: ${{ secrets.PYPI_TOKEN }} + + # ── Go SDK → tag validation ───────────────────────────────────────────────── + validate-go-sdk: + name: Validate Go SDK + runs-on: ubuntu-latest + if: startsWith(github.ref_name, 'go-sdk-v') + defaults: + run: + working-directory: ./fluxapay_go_sdk + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Validate tag matches go.mod module path + run: | + TAG_VERSION="${GITHUB_REF_NAME#go-sdk-v}" + echo "Releasing Go SDK v$TAG_VERSION" + go vet ./... + + - name: Run tests + run: go test ./... -v + + - name: Build check + run: go build ./... diff --git a/.gitignore b/.gitignore index d9418239..0e836266 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ .next/ out/ coverage/ +fluxapay_backend/swagger.json # TypeScript *.tsbuildinfo diff --git a/fluxapay_backend/docs/PAYMENT_LIFECYCLE.md b/fluxapay_backend/docs/PAYMENT_LIFECYCLE.md new file mode 100644 index 00000000..82548b73 --- /dev/null +++ b/fluxapay_backend/docs/PAYMENT_LIFECYCLE.md @@ -0,0 +1,100 @@ +# Payment Status Lifecycle + +This document describes every `PaymentStatus` value, what triggers the +transition, and which downstream actions follow. + +> Canonical enum is defined in `prisma/schema.prisma` and mirrored in +> `fluxapay_backend/src/types/payment.ts` (TypeScript) and +> `fluxapay_frontend/src/types/payment.ts` (frontend). + +--- + +## States + +| Status | Description | +|----------------------|-------------------------------------------------------------------------------------------------------| +| `pending` | Payment created; awaiting USDC deposit from the customer. | +| `partially_paid` | A deposit has been received but it is less than the required USDC amount. | +| `confirmed` | Full USDC amount received and verified on-chain. Settlement processing begins. | +| `overpaid` | More USDC than required was received. Excess handling is per merchant configuration. | +| `expired` | Payment window (15 min default) elapsed without sufficient funds received. | +| `failed` | On-chain verification or settlement processing failed irrecoverably. | +| `paid` | Synonym for `confirmed` — used in some legacy API responses; maps to confirmed internally. | +| `completed` | Settlement has been disbursed to the merchant's bank account. | +| `cancelled` | Payment was cancelled (e.g. merchant account deleted, admin action). | +| `refunded` | Full refund has been processed and USDC returned to the payer. | +| `partially_refunded` | A partial refund has been issued; some funds were returned to the payer. | + +--- + +## Transition Diagram + +``` + ┌─────────────────────────────────┐ + │ │ + ┌───────▼──────┐ │ + CREATE ──► │ pending │ │ + └──────┬───────┘ │ + │ partial deposit │ + ▼ │ + ┌─────────────────┐ │ + │ partially_paid │ │ + └────────┬────────┘ │ + │ full deposit received │ + │ (also from pending directly) │ + ▼ │ + ┌──────────────┐ overpayment │ + │ confirmed ├──────────────► overpaid │ + └──────┬───────┘ │ │ + │ │ │ + │ settlement complete │ settlement│ + ▼ ▼ │ + ┌──────────────┐ ┌──────────────┐ │ + │ completed │ │ completed │ │ + └──────┬───────┘ └──────────────┘ │ + │ │ + ┌───────┴────────────┐ │ + │ refund requested │ │ + ▼ ▼ │ + ┌──────────────┐ ┌──────────────────────┐ │ + │ refunded │ │ partially_refunded │ │ + └──────────────┘ └──────────────────────┘ │ + │ + pending / partially_paid ──► expired (TTL elapsed) ─────┘ + any non-terminal ──► failed (processing error) + any non-terminal ──► cancelled (admin / account deleted) +``` + +--- + +## Terminal States + +A terminal state is one that cannot transition further: + +- `expired` +- `failed` +- `paid` *(legacy)* +- `completed` +- `refunded` +- `partially_refunded` +- `cancelled` + +Polling and SSE connections close once a terminal state is reached. + +--- + +## Webhook Events + +| Status transition | Webhook event emitted | +|---------------------------|-------------------------------| +| → `pending` | `payment.pending` | +| → `confirmed` | `payment.confirmed` | +| → `failed` | `payment.failed` | +| → `completed` | `payment.settled` | +| → `expired` | `payment.expired` | +| → `partially_paid` | `payment.partially_paid` | +| → `overpaid` | `payment.overpaid` *(if configured)* | + +--- + +*Last updated: 2026-07-23 — closes issue #626* diff --git a/fluxapay_backend/prisma/migrations/20260126041238_add_webhook_logs/migration.sql b/fluxapay_backend/prisma/migrations/20260126041238_add_webhook_logs/migration.sql index eefa08dd..cba789c8 100644 --- a/fluxapay_backend/prisma/migrations/20260126041238_add_webhook_logs/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260126041238_add_webhook_logs/migration.sql @@ -11,7 +11,7 @@ CREATE TYPE "WebhookEventType" AS ENUM ('payment_completed', 'payment_failed', ' CREATE TYPE "WebhookStatus" AS ENUM ('pending', 'delivered', 'failed', 'retrying'); -- CreateTable -CREATE TABLE "Merchant" ( +CREATE TABLE IF NOT EXISTS "Merchant" ( "id" TEXT NOT NULL, "business_name" TEXT NOT NULL, "email" TEXT NOT NULL, @@ -27,7 +27,7 @@ CREATE TABLE "Merchant" ( ); -- CreateTable -CREATE TABLE "OTP" ( +CREATE TABLE IF NOT EXISTS "OTP" ( "id" TEXT NOT NULL, "merchantId" TEXT NOT NULL, "channel" "OTPChannel" NOT NULL, @@ -39,7 +39,7 @@ CREATE TABLE "OTP" ( ); -- CreateTable -CREATE TABLE "WebhookLog" ( +CREATE TABLE IF NOT EXISTS "WebhookLog" ( "id" TEXT NOT NULL, "merchantId" TEXT NOT NULL, "event_type" "WebhookEventType" NOT NULL, @@ -59,7 +59,7 @@ CREATE TABLE "WebhookLog" ( ); -- CreateTable -CREATE TABLE "WebhookRetryAttempt" ( +CREATE TABLE IF NOT EXISTS "WebhookRetryAttempt" ( "id" TEXT NOT NULL, "webhookLogId" TEXT NOT NULL, "attempt_number" INTEGER NOT NULL, @@ -72,28 +72,28 @@ CREATE TABLE "WebhookRetryAttempt" ( ); -- CreateIndex -CREATE UNIQUE INDEX "Merchant_email_key" ON "Merchant"("email"); +CREATE UNIQUE INDEX IF NOT EXISTS "Merchant_email_key" ON "Merchant"("email"); -- CreateIndex -CREATE UNIQUE INDEX "Merchant_phone_number_key" ON "Merchant"("phone_number"); +CREATE UNIQUE INDEX IF NOT EXISTS "Merchant_phone_number_key" ON "Merchant"("phone_number"); -- CreateIndex -CREATE UNIQUE INDEX "OTP_merchantId_channel_key" ON "OTP"("merchantId", "channel"); +CREATE UNIQUE INDEX IF NOT EXISTS "OTP_merchantId_channel_key" ON "OTP"("merchantId", "channel"); -- CreateIndex -CREATE INDEX "WebhookLog_merchantId_idx" ON "WebhookLog"("merchantId"); +CREATE INDEX IF NOT EXISTS "WebhookLog_merchantId_idx" ON "WebhookLog"("merchantId"); -- CreateIndex -CREATE INDEX "WebhookLog_event_type_idx" ON "WebhookLog"("event_type"); +CREATE INDEX IF NOT EXISTS "WebhookLog_event_type_idx" ON "WebhookLog"("event_type"); -- CreateIndex -CREATE INDEX "WebhookLog_status_idx" ON "WebhookLog"("status"); +CREATE INDEX IF NOT EXISTS "WebhookLog_status_idx" ON "WebhookLog"("status"); -- CreateIndex -CREATE INDEX "WebhookLog_payment_id_idx" ON "WebhookLog"("payment_id"); +CREATE INDEX IF NOT EXISTS "WebhookLog_payment_id_idx" ON "WebhookLog"("payment_id"); -- CreateIndex -CREATE INDEX "WebhookRetryAttempt_webhookLogId_idx" ON "WebhookRetryAttempt"("webhookLogId"); +CREATE INDEX IF NOT EXISTS "WebhookRetryAttempt_webhookLogId_idx" ON "WebhookRetryAttempt"("webhookLogId"); -- AddForeignKey ALTER TABLE "OTP" ADD CONSTRAINT "OTP_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/fluxapay_backend/prisma/migrations/20260201000000_baseline_missing_tables/migration.sql b/fluxapay_backend/prisma/migrations/20260201000000_baseline_missing_tables/migration.sql new file mode 100644 index 00000000..bfad7e39 --- /dev/null +++ b/fluxapay_backend/prisma/migrations/20260201000000_baseline_missing_tables/migration.sql @@ -0,0 +1,529 @@ +-- Baseline migration: Create all tables and enums that were originally +-- applied via `prisma db push` and never captured in a migration file. +-- This migration must run BEFORE 20260325182803_add_high_cardinality_indexes. + +-- ─── Enums ─────────────────────────────────────────────────────────────────── + +-- SettlementStatus +DO $$ BEGIN + CREATE TYPE "SettlementStatus" AS ENUM ('pending', 'processing', 'completed', 'failed'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- BusinessType +DO $$ BEGIN + CREATE TYPE "BusinessType" AS ENUM ('individual', 'registered_business'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- GovernmentIdType +DO $$ BEGIN + CREATE TYPE "GovernmentIdType" AS ENUM ('passport', 'national_id', 'driver_license'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- KYCStatus +DO $$ BEGIN + CREATE TYPE "KYCStatus" AS ENUM ('not_submitted', 'pending_review', 'approved', 'rejected'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- DocumentType +DO $$ BEGIN + CREATE TYPE "DocumentType" AS ENUM ('government_id', 'proof_of_business_registration', 'proof_of_address'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- RefundStatus +DO $$ BEGIN + CREATE TYPE "RefundStatus" AS ENUM ('pending', 'processing', 'completed', 'failed'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- InvoiceStatus +DO $$ BEGIN + CREATE TYPE "InvoiceStatus" AS ENUM ('draft', 'sent', 'paid', 'overdue', 'voided'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ReconciliationStatus +DO $$ BEGIN + CREATE TYPE "ReconciliationStatus" AS ENUM ('ok', 'discrepancy', 'discrepancy_detected', 'duplicate_payment'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- AlertSeverity +DO $$ BEGIN + CREATE TYPE "AlertSeverity" AS ENUM ('low', 'medium', 'high', 'critical'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- WebhookDeliveryStatus +DO $$ BEGIN + CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'SUCCESS', 'FAILED'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ─── Tables ────────────────────────────────────────────────────────────────── + +-- Settlement +CREATE TABLE IF NOT EXISTS "Settlement" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "usdc_amount" DECIMAL(65,30), + "amount" DECIMAL(65,30) NOT NULL, + "fees" DECIMAL(65,30) NOT NULL, + "net_amount" DECIMAL(65,30), + "currency" TEXT NOT NULL, + "status" "SettlementStatus" NOT NULL DEFAULT 'pending', + "exchange_partner" TEXT, + "exchange_rate" DECIMAL(65,30), + "exchange_ref" TEXT, + "bank_transfer_id" TEXT, + "breakdown" JSONB, + "payment_ids" JSONB, + "failure_reason" TEXT, + "payout_partner_payload" JSONB, + "scheduled_date" TIMESTAMP(3) NOT NULL, + "processed_date" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id") +); + +-- MerchantKYC +CREATE TABLE IF NOT EXISTS "MerchantKYC" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "business_type" "BusinessType" NOT NULL, + "legal_business_name" TEXT NOT NULL, + "business_registration_number" TEXT, + "country_of_registration" TEXT NOT NULL, + "business_address" TEXT NOT NULL, + "director_full_name" TEXT NOT NULL, + "director_email" TEXT NOT NULL, + "director_phone" TEXT NOT NULL, + "government_id_type" "GovernmentIdType" NOT NULL, + "government_id_number" TEXT NOT NULL, + "kyc_status" "KYCStatus" NOT NULL DEFAULT 'not_submitted', + "rejection_reason" TEXT, + "reviewed_at" TIMESTAMP(3), + "reviewed_by" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MerchantKYC_pkey" PRIMARY KEY ("id") +); + +-- KYCDocument +CREATE TABLE IF NOT EXISTS "KYCDocument" ( + "id" TEXT NOT NULL, + "kycId" TEXT NOT NULL, + "document_type" "DocumentType" NOT NULL, + "file_name" TEXT NOT NULL, + "file_url" TEXT NOT NULL, + "public_id" TEXT NOT NULL, + "file_size" INTEGER NOT NULL, + "mime_type" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "KYCDocument_pkey" PRIMARY KEY ("id") +); + +-- BankAccount +CREATE TABLE IF NOT EXISTS "BankAccount" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "account_name" TEXT NOT NULL, + "account_number" TEXT NOT NULL, + "bank_name" TEXT NOT NULL, + "bank_code" TEXT, + "currency" TEXT NOT NULL, + "country" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "BankAccount_pkey" PRIMARY KEY ("id") +); + +-- MerchantHDIndex +CREATE TABLE IF NOT EXISTS "MerchantHDIndex" ( + "merchantId" TEXT NOT NULL, + "merchant_index" INTEGER NOT NULL, + "payment_counter" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MerchantHDIndex_pkey" PRIMARY KEY ("merchantId") +); + +-- HDIndexCounter +CREATE TABLE IF NOT EXISTS "HDIndexCounter" ( + "id" TEXT NOT NULL DEFAULT 'global', + "next_merchant_index" INTEGER NOT NULL DEFAULT 0, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "HDIndexCounter_pkey" PRIMARY KEY ("id") +); + +-- ManualIntervention +CREATE TABLE IF NOT EXISTS "ManualIntervention" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "issue_type" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ManualIntervention_pkey" PRIMARY KEY ("id") +); + +-- Plan +CREATE TABLE IF NOT EXISTS "Plan" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "amount" DECIMAL(65,30) NOT NULL, + "currency" TEXT NOT NULL, + "interval" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "api_call_limit" INTEGER, + "charge_limit" INTEGER, + "settlement_volume_limit" DECIMAL(65,30), + "overage_mode" TEXT NOT NULL DEFAULT 'hard_block', + "features" JSONB NOT NULL DEFAULT '[]', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- MerchantSubscription +CREATE TABLE IF NOT EXISTS "MerchantSubscription" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "billing_cycle" TEXT NOT NULL, + "current_period_start" TIMESTAMP(3) NOT NULL, + "current_period_end" TIMESTAMP(3) NOT NULL, + "next_billing_date" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "MerchantSubscription_pkey" PRIMARY KEY ("id") +); + +-- Payment +CREATE TABLE IF NOT EXISTS "Payment" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "amount" DECIMAL(65,30) NOT NULL, + "currency" TEXT NOT NULL, + "usdc_amount" DECIMAL(65,30), + "fx_rate" DECIMAL(65,30), + "customer_email" TEXT NOT NULL, + "description" TEXT, + "metadata" JSONB NOT NULL, + "expiration" TIMESTAMP(3) NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "paid_amount" DECIMAL(65,30) DEFAULT 0, + "last_seen_at" TIMESTAMP(3), + "checkout_url" TEXT NOT NULL, + "success_url" TEXT, + "cancel_url" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "stellar_address" TEXT, + "derivation_path" TEXT, + "encrypted_key_data" TEXT, + "payment_index" INTEGER, + "transaction_hash" TEXT, + "contract_tx_hash" TEXT, + "payer_address" TEXT, + "onchain_verified" BOOLEAN DEFAULT false, + "verification_error" TEXT, + "last_paging_token" TEXT, + "swept" BOOLEAN NOT NULL DEFAULT false, + "settled" BOOLEAN NOT NULL DEFAULT false, + "swept_at" TIMESTAMP(3), + "sweep_tx_hash" TEXT, + "confirmed_at" TIMESTAMP(3), + "settled_at" TIMESTAMP(3), + "settlement_ref" TEXT, + "settlement_fiat_amount" DECIMAL(65,30), + "settlement_fiat_currency" TEXT, + "settlementId" TEXT, + "paymentLinkId" TEXT, + "reminder_sent_at" TIMESTAMP(3), + "webhook_status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "webhook_retries" INTEGER NOT NULL DEFAULT 0, + "escrow_mode" BOOLEAN NOT NULL DEFAULT false, + "escrow_contract_address" TEXT, + "escrow_status" TEXT, + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- Refund +CREATE TABLE IF NOT EXISTS "Refund" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "paymentId" TEXT NOT NULL, + "amount" DECIMAL(65,30) NOT NULL, + "currency" TEXT NOT NULL, + "reason" TEXT, + "status" "RefundStatus" NOT NULL DEFAULT 'pending', + "failed_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Refund_pkey" PRIMARY KEY ("id") +); + +-- Invoice +CREATE TABLE IF NOT EXISTS "Invoice" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "invoice_number" TEXT NOT NULL, + "amount" DECIMAL(65,30) NOT NULL, + "currency" TEXT NOT NULL, + "subtotal" DECIMAL(65,30), + "tax_amount" DECIMAL(65,30), + "tax_rate" DECIMAL(65,30), + "customer_email" TEXT NOT NULL, + "customer_name" TEXT, + "line_items" JSONB, + "notes" TEXT, + "metadata" JSONB, + "payment_id" TEXT, + "payment_link" TEXT NOT NULL, + "status" "InvoiceStatus" NOT NULL DEFAULT 'draft', + "due_date" TIMESTAMP(3), + "sent_at" TIMESTAMP(3), + "voided_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id") +); + +-- ReconciliationRecord +CREATE TABLE IF NOT EXISTS "ReconciliationRecord" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "period_start" TIMESTAMP(3) NOT NULL, + "period_end" TIMESTAMP(3) NOT NULL, + "expected_total" DECIMAL(65,30) NOT NULL, + "actual_total" DECIMAL(65,30) NOT NULL, + "discrepancy_amount" DECIMAL(65,30) NOT NULL, + "discrepancy_percent" DECIMAL(65,30) NOT NULL, + "status" "ReconciliationStatus" NOT NULL DEFAULT 'ok', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReconciliationRecord_pkey" PRIMARY KEY ("id") +); + +-- DailyReconciliationReport +CREATE TABLE IF NOT EXISTS "DailyReconciliationReport" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "report_date" TIMESTAMP(3) NOT NULL, + "total_charges" INTEGER NOT NULL DEFAULT 0, + "total_volume_usdc" DECIMAL(65,30) NOT NULL DEFAULT 0, + "total_volume_fiat" DECIMAL(65,30) NOT NULL DEFAULT 0, + "total_fees" DECIMAL(65,30) NOT NULL DEFAULT 0, + "total_net_settled" DECIMAL(65,30) NOT NULL DEFAULT 0, + "report_data" JSONB NOT NULL DEFAULT '{}', + "csv_url" TEXT, + "emailed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DailyReconciliationReport_pkey" PRIMARY KEY ("id") +); + +-- DiscrepancyThreshold +CREATE TABLE IF NOT EXISTS "DiscrepancyThreshold" ( + "id" TEXT NOT NULL, + "merchantId" TEXT, + "amount_threshold" DECIMAL(65,30) NOT NULL DEFAULT 0, + "percent_threshold" DECIMAL(65,30) NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DiscrepancyThreshold_pkey" PRIMARY KEY ("id") +); + +-- DiscrepancyAlert +CREATE TABLE IF NOT EXISTS "DiscrepancyAlert" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "reconciliationRecordId" TEXT NOT NULL, + "thresholdId" TEXT, + "severity" "AlertSeverity" NOT NULL, + "discrepancy_type" TEXT, + "details" JSONB, + "message" TEXT NOT NULL, + "is_resolved" BOOLEAN NOT NULL DEFAULT false, + "resolved_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "DiscrepancyAlert_pkey" PRIMARY KEY ("id") +); + +-- PaymentLink +CREATE TABLE IF NOT EXISTS "PaymentLink" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "amount" DECIMAL(65,30), + "currency" TEXT NOT NULL, + "redirect_url" TEXT, + "expiry" TIMESTAMP(3), + "active" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB NOT NULL DEFAULT '{}', + "total_payments" INTEGER NOT NULL DEFAULT 0, + "total_volume" DECIMAL(65,30) NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "customerId" TEXT, + CONSTRAINT "PaymentLink_pkey" PRIMARY KEY ("id") +); + +-- WorkerState +CREATE TABLE IF NOT EXISTS "WorkerState" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + CONSTRAINT "WorkerState_pkey" PRIMARY KEY ("key") +); + +-- RefreshToken +CREATE TABLE IF NOT EXISTS "RefreshToken" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "is_revoked" BOOLEAN NOT NULL DEFAULT false, + "is_reused" BOOLEAN NOT NULL DEFAULT false, + "created_at_ip" TEXT, + "created_at_user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- LoginAttempt +CREATE TABLE IF NOT EXISTS "LoginAttempt" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "ip_address" TEXT NOT NULL, + "success" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id") +); + +-- RateLimitLog +CREATE TABLE IF NOT EXISTS "RateLimitLog" ( + "id" TEXT NOT NULL, + "ip_address" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "limit_type" TEXT NOT NULL, + "retry_after_seconds" INTEGER NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RateLimitLog_pkey" PRIMARY KEY ("id") +); + +-- ApiKey +CREATE TABLE IF NOT EXISTS "ApiKey" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "key_last_four" TEXT NOT NULL, + "environment" TEXT NOT NULL DEFAULT 'live', + "status" TEXT NOT NULL DEFAULT 'active', + "last_used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- ─── Unique Constraints ───────────────────────────────────────────────────── + +CREATE UNIQUE INDEX IF NOT EXISTS "MerchantKYC_merchantId_key" ON "MerchantKYC"("merchantId"); +CREATE UNIQUE INDEX IF NOT EXISTS "BankAccount_merchantId_key" ON "BankAccount"("merchantId"); +CREATE UNIQUE INDEX IF NOT EXISTS "MerchantHDIndex_merchant_index_key" ON "MerchantHDIndex"("merchant_index"); +CREATE UNIQUE INDEX IF NOT EXISTS "Plan_slug_key" ON "Plan"("slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "Invoice_invoice_number_key" ON "Invoice"("invoice_number"); +CREATE UNIQUE INDEX IF NOT EXISTS "Invoice_payment_id_key" ON "Invoice"("payment_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "ReconciliationRecord_merchantId_period_start_period_end_key" ON "ReconciliationRecord"("merchantId", "period_start", "period_end"); +CREATE UNIQUE INDEX IF NOT EXISTS "DailyReconciliationReport_merchantId_report_date_key" ON "DailyReconciliationReport"("merchantId", "report_date"); +CREATE UNIQUE INDEX IF NOT EXISTS "PaymentLink_slug_key" ON "PaymentLink"("slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "RefreshToken_token_hash_key" ON "RefreshToken"("token_hash"); +CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_key_hash_key" ON "ApiKey"("key_hash"); + +-- ─── Regular Indexes ──────────────────────────────────────────────────────── + +CREATE INDEX IF NOT EXISTS "Settlement_merchantId_status_idx" ON "Settlement"("merchantId", "status"); +CREATE INDEX IF NOT EXISTS "Settlement_merchantId_scheduled_date_idx" ON "Settlement"("merchantId", "scheduled_date" DESC); +CREATE INDEX IF NOT EXISTS "Payment_swept_status_idx" ON "Payment"("swept", "status"); +CREATE INDEX IF NOT EXISTS "Refund_merchantId_status_created_at_idx" ON "Refund"("merchantId", "status", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "Invoice_merchantId_created_at_idx" ON "Invoice"("merchantId", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "Invoice_merchantId_status_created_at_idx" ON "Invoice"("merchantId", "status", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "DiscrepancyThreshold_merchantId_is_active_idx" ON "DiscrepancyThreshold"("merchantId", "is_active"); +CREATE INDEX IF NOT EXISTS "DiscrepancyAlert_merchantId_is_resolved_idx" ON "DiscrepancyAlert"("merchantId", "is_resolved"); +CREATE INDEX IF NOT EXISTS "DailyReconciliationReport_merchantId_idx" ON "DailyReconciliationReport"("merchantId"); +CREATE INDEX IF NOT EXISTS "DailyReconciliationReport_report_date_idx" ON "DailyReconciliationReport"("report_date"); +CREATE INDEX IF NOT EXISTS "PaymentLink_merchantId_idx" ON "PaymentLink"("merchantId"); +CREATE INDEX IF NOT EXISTS "PaymentLink_slug_idx" ON "PaymentLink"("slug"); +CREATE INDEX IF NOT EXISTS "PaymentLink_active_idx" ON "PaymentLink"("active"); +CREATE INDEX IF NOT EXISTS "PaymentLink_expiry_idx" ON "PaymentLink"("expiry"); +CREATE INDEX IF NOT EXISTS "RefreshToken_merchantId_idx" ON "RefreshToken"("merchantId"); +CREATE INDEX IF NOT EXISTS "RefreshToken_token_hash_idx" ON "RefreshToken"("token_hash"); +CREATE INDEX IF NOT EXISTS "RefreshToken_expires_at_idx" ON "RefreshToken"("expires_at"); +CREATE INDEX IF NOT EXISTS "LoginAttempt_merchantId_idx" ON "LoginAttempt"("merchantId"); +CREATE INDEX IF NOT EXISTS "LoginAttempt_email_idx" ON "LoginAttempt"("email"); +CREATE INDEX IF NOT EXISTS "LoginAttempt_ip_address_idx" ON "LoginAttempt"("ip_address"); +CREATE INDEX IF NOT EXISTS "LoginAttempt_created_at_idx" ON "LoginAttempt"("created_at"); +CREATE INDEX IF NOT EXISTS "RateLimitLog_ip_address_idx" ON "RateLimitLog"("ip_address"); +CREATE INDEX IF NOT EXISTS "RateLimitLog_timestamp_idx" ON "RateLimitLog"("timestamp"); +CREATE INDEX IF NOT EXISTS "RateLimitLog_limit_type_idx" ON "RateLimitLog"("limit_type"); +CREATE INDEX IF NOT EXISTS "ApiKey_merchantId_idx" ON "ApiKey"("merchantId"); +CREATE INDEX IF NOT EXISTS "ApiKey_merchantId_status_idx" ON "ApiKey"("merchantId", "status"); +CREATE INDEX IF NOT EXISTS "ApiKey_key_hash_idx" ON "ApiKey"("key_hash"); + +-- ─── Foreign Keys ─────────────────────────────────────────────────────────── + +ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "MerchantKYC" ADD CONSTRAINT "MerchantKYC_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "KYCDocument" ADD CONSTRAINT "KYCDocument_kycId_fkey" FOREIGN KEY ("kycId") REFERENCES "MerchantKYC"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "BankAccount" ADD CONSTRAINT "BankAccount_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "MerchantHDIndex" ADD CONSTRAINT "MerchantHDIndex_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "MerchantSubscription" ADD CONSTRAINT "MerchantSubscription_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "MerchantSubscription" ADD CONSTRAINT "MerchantSubscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_settlementId_fkey" FOREIGN KEY ("settlementId") REFERENCES "Settlement"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Refund" ADD CONSTRAINT "Refund_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Refund" ADD CONSTRAINT "Refund_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "ReconciliationRecord" ADD CONSTRAINT "ReconciliationRecord_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "DailyReconciliationReport" ADD CONSTRAINT "DailyReconciliationReport_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "DiscrepancyThreshold" ADD CONSTRAINT "DiscrepancyThreshold_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "DiscrepancyAlert" ADD CONSTRAINT "DiscrepancyAlert_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "DiscrepancyAlert" ADD CONSTRAINT "DiscrepancyAlert_reconciliationRecordId_fkey" FOREIGN KEY ("reconciliationRecordId") REFERENCES "ReconciliationRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "DiscrepancyAlert" ADD CONSTRAINT "DiscrepancyAlert_thresholdId_fkey" FOREIGN KEY ("thresholdId") REFERENCES "DiscrepancyThreshold"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "PaymentLink" ADD CONSTRAINT "PaymentLink_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Also add missing columns / constraints to Merchant that were applied via db push +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "webhook_url" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "webhook_secret" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "api_key_hashed" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "api_key_last_four" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "settlement_schedule" TEXT DEFAULT 'daily'; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "settlement_day" INTEGER; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "settlement_fee_percent" DECIMAL(65,30) NOT NULL DEFAULT 1.5; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "checkout_logo_url" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "checkout_accent_color" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "email_notifications_enabled" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "notify_on_payment" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "notify_on_settlement" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "onchain_registered" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "onchain_registry_tx_hash" TEXT; +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "deletion_requested_at" TIMESTAMP(3); +ALTER TABLE "Merchant" ADD COLUMN IF NOT EXISTS "anonymized_at" TIMESTAMP(3); diff --git a/fluxapay_backend/prisma/migrations/20260325182803_add_high_cardinality_indexes/migration.sql b/fluxapay_backend/prisma/migrations/20260325182803_add_high_cardinality_indexes/migration.sql index 23514c31..4f2bfd29 100644 --- a/fluxapay_backend/prisma/migrations/20260325182803_add_high_cardinality_indexes/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260325182803_add_high_cardinality_indexes/migration.sql @@ -2,10 +2,10 @@ -- These indexes optimize list endpoints and admin dashboard queries that filter by merchantId and created/createdAt timestamps -- Add composite index on Payment(merchantId, createdAt) for efficient merchant payment list queries -CREATE INDEX "Payment_merchantId_createdAt_idx" ON "Payment"("merchantId", "createdAt" DESC); +CREATE INDEX IF NOT EXISTS "Payment_merchantId_createdAt_idx" ON "Payment"("merchantId", "createdAt" DESC); -- Add composite index on Invoice(merchantId, created_at) for efficient merchant invoice list queries -CREATE INDEX "Invoice_merchantId_created_at_idx" ON "Invoice"("merchantId", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "Invoice_merchantId_created_at_idx" ON "Invoice"("merchantId", "created_at" DESC); -- Add composite index on WebhookLog(merchantId, created_at) for efficient merchant webhook log list queries -CREATE INDEX "WebhookLog_merchantId_created_at_idx" ON "WebhookLog"("merchantId", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "WebhookLog_merchantId_created_at_idx" ON "WebhookLog"("merchantId", "created_at" DESC); diff --git a/fluxapay_backend/prisma/migrations/20260327000000_add_payment_redirect_fields/migration.sql b/fluxapay_backend/prisma/migrations/20260327000000_add_payment_redirect_fields/migration.sql index f7b0b37d..10b673c9 100644 --- a/fluxapay_backend/prisma/migrations/20260327000000_add_payment_redirect_fields/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260327000000_add_payment_redirect_fields/migration.sql @@ -1,4 +1,4 @@ -- Add redirect URL fields and description to Payment table -ALTER TABLE "Payment" ADD COLUMN "description" TEXT; -ALTER TABLE "Payment" ADD COLUMN "success_url" TEXT; -ALTER TABLE "Payment" ADD COLUMN "cancel_url" TEXT; +ALTER TABLE "Payment" ADD COLUMN IF NOT EXISTS "description" TEXT; +ALTER TABLE "Payment" ADD COLUMN IF NOT EXISTS "success_url" TEXT; +ALTER TABLE "Payment" ADD COLUMN IF NOT EXISTS "cancel_url" TEXT; diff --git a/fluxapay_backend/prisma/migrations/20260328120000_add_customer_and_payment_customer_link/migration.sql b/fluxapay_backend/prisma/migrations/20260328120000_add_customer_and_payment_customer_link/migration.sql index feaa200b..1b51ac33 100644 --- a/fluxapay_backend/prisma/migrations/20260328120000_add_customer_and_payment_customer_link/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260328120000_add_customer_and_payment_customer_link/migration.sql @@ -1,5 +1,5 @@ -- Merchant-scoped customers; optional link from Payment (issue #298) -CREATE TABLE "Customer" ( +CREATE TABLE IF NOT EXISTS "Customer" ( "id" TEXT NOT NULL, "merchantId" TEXT NOT NULL, "email" TEXT NOT NULL, @@ -10,13 +10,19 @@ CREATE TABLE "Customer" ( CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") ); -CREATE INDEX "Customer_merchantId_idx" ON "Customer"("merchantId"); -CREATE INDEX "Customer_merchantId_email_idx" ON "Customer"("merchantId", "email"); +CREATE INDEX IF NOT EXISTS "Customer_merchantId_idx" ON "Customer"("merchantId"); +CREATE INDEX IF NOT EXISTS "Customer_merchantId_email_idx" ON "Customer"("merchantId", "email"); ALTER TABLE "Customer" ADD CONSTRAINT "Customer_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "Payment" ADD COLUMN "customerId" TEXT; +ALTER TABLE "Payment" ADD COLUMN IF NOT EXISTS "customerId" TEXT; -CREATE INDEX "Payment_customerId_idx" ON "Payment"("customerId"); +CREATE INDEX IF NOT EXISTS "Payment_customerId_idx" ON "Payment"("customerId"); -ALTER TABLE "Payment" ADD CONSTRAINT "Payment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'Payment_customerId_fkey' + ) THEN + ALTER TABLE "Payment" ADD CONSTRAINT "Payment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; diff --git a/fluxapay_backend/prisma/migrations/20260328210000_add_webhook_event_id/migration.sql b/fluxapay_backend/prisma/migrations/20260328210000_add_webhook_event_id/migration.sql index 3812ad33..1978318e 100644 --- a/fluxapay_backend/prisma/migrations/20260328210000_add_webhook_event_id/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260328210000_add_webhook_event_id/migration.sql @@ -1,3 +1,3 @@ -- Add stable event_id to WebhookLog for idempotent delivery deduplication -ALTER TABLE "WebhookLog" ADD COLUMN "event_id" TEXT; -CREATE UNIQUE INDEX "WebhookLog_event_id_key" ON "WebhookLog"("event_id"); +ALTER TABLE "WebhookLog" ADD COLUMN IF NOT EXISTS "event_id" TEXT; +CREATE UNIQUE INDEX IF NOT EXISTS "WebhookLog_event_id_key" ON "WebhookLog"("event_id"); diff --git a/fluxapay_backend/prisma/migrations/20260329000000_add_admin_user_rbac/migration.sql b/fluxapay_backend/prisma/migrations/20260329000000_add_admin_user_rbac/migration.sql index 981afe72..e4f96b4f 100644 --- a/fluxapay_backend/prisma/migrations/20260329000000_add_admin_user_rbac/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260329000000_add_admin_user_rbac/migration.sql @@ -2,7 +2,7 @@ CREATE TYPE "AdminRole" AS ENUM ('support', 'finance', 'super_admin'); -- CreateTable -CREATE TABLE "AdminUser" ( +CREATE TABLE IF NOT EXISTS "AdminUser" ( "id" TEXT NOT NULL, "email" TEXT NOT NULL, "password" TEXT NOT NULL, @@ -15,4 +15,4 @@ CREATE TABLE "AdminUser" ( ); -- CreateIndex -CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email"); +CREATE UNIQUE INDEX IF NOT EXISTS "AdminUser_email_key" ON "AdminUser"("email"); diff --git a/fluxapay_backend/prisma/migrations/20260329140000_add_data_export_job/migration.sql b/fluxapay_backend/prisma/migrations/20260329140000_add_data_export_job/migration.sql index 21642a74..c47a6c25 100644 --- a/fluxapay_backend/prisma/migrations/20260329140000_add_data_export_job/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260329140000_add_data_export_job/migration.sql @@ -2,7 +2,7 @@ CREATE TYPE "DataExportStatus" AS ENUM ('pending', 'processing', 'completed', 'failed'); -- CreateTable -CREATE TABLE "DataExportJob" ( +CREATE TABLE IF NOT EXISTS "DataExportJob" ( "id" TEXT NOT NULL, "merchantId" TEXT NOT NULL, "status" "DataExportStatus" NOT NULL DEFAULT 'pending', @@ -17,7 +17,7 @@ CREATE TABLE "DataExportJob" ( ); -- CreateIndex -CREATE INDEX "DataExportJob_merchantId_idx" ON "DataExportJob"("merchantId"); +CREATE INDEX IF NOT EXISTS "DataExportJob_merchantId_idx" ON "DataExportJob"("merchantId"); -- AddForeignKey ALTER TABLE "DataExportJob" ADD CONSTRAINT "DataExportJob_merchantId_fkey" diff --git a/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql b/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql index ae1ba532..6ee83688 100644 --- a/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260329143000_merchant_deletion_anonymization/migration.sql @@ -1,7 +1,7 @@ -- Add compliance columns to Merchant ALTER TABLE "Merchant" - ADD COLUMN "deletion_requested_at" TIMESTAMP(3), - ADD COLUMN "anonymized_at" TIMESTAMP(3); + ADD COLUMN IF NOT EXISTS "deletion_requested_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "anonymized_at" TIMESTAMP(3); -- Extend AuditActionType enum ALTER TYPE "AuditActionType" ADD VALUE IF NOT EXISTS 'merchant_deletion_requested'; @@ -11,7 +11,7 @@ ALTER TYPE "AuditActionType" ADD VALUE IF NOT EXISTS 'merchant_anonymized'; ALTER TYPE "AuditEntityType" ADD VALUE IF NOT EXISTS 'merchant_account'; -- CreateTable: MerchantDeletionRequest -CREATE TABLE "MerchantDeletionRequest" ( +CREATE TABLE IF NOT EXISTS "MerchantDeletionRequest" ( "id" TEXT NOT NULL, "merchantId" TEXT NOT NULL, "reason" TEXT, @@ -23,8 +23,8 @@ CREATE TABLE "MerchantDeletionRequest" ( CONSTRAINT "MerchantDeletionRequest_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "MerchantDeletionRequest_merchantId_key" +CREATE UNIQUE INDEX IF NOT EXISTS "MerchantDeletionRequest_merchantId_key" ON "MerchantDeletionRequest"("merchantId"); -CREATE INDEX "MerchantDeletionRequest_merchantId_idx" +CREATE INDEX IF NOT EXISTS "MerchantDeletionRequest_merchantId_idx" ON "MerchantDeletionRequest"("merchantId"); diff --git a/fluxapay_backend/prisma/migrations/20260424000002_add_payment_tracking_fields/migration.sql b/fluxapay_backend/prisma/migrations/20260424000002_add_payment_tracking_fields/migration.sql index 37fd009c..4d238fef 100644 --- a/fluxapay_backend/prisma/migrations/20260424000002_add_payment_tracking_fields/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260424000002_add_payment_tracking_fields/migration.sql @@ -2,35 +2,26 @@ -- Issue #448: Payment monitor: handle underpayment threshold and timeout rules -- Step 1: Add paid_amount field to track cumulative payments -ALTER TABLE "Payment" -ADD COLUMN "paid_amount" DECIMAL(65,30) DEFAULT 0; +ALTER TABLE "Payment" +ADD COLUMN IF NOT EXISTS "paid_amount" DECIMAL(65,30) DEFAULT 0; -- Step 2: Add last_seen_at field to track when payment was last detected -ALTER TABLE "Payment" -ADD COLUMN "last_seen_at" TIMESTAMP(3); +ALTER TABLE "Payment" +ADD COLUMN IF NOT EXISTS "last_seen_at" TIMESTAMP(3); -- Step 3: Create indexes for performance -CREATE INDEX "Payment_paid_amount_idx" ON "Payment"("paid_amount") +CREATE INDEX IF NOT EXISTS "Payment_paid_amount_idx" ON "Payment"("paid_amount") WHERE "paid_amount" > 0; -CREATE INDEX "Payment_last_seen_at_idx" ON "Payment"("last_seen_at") +CREATE INDEX IF NOT EXISTS "Payment_last_seen_at_idx" ON "Payment"("last_seen_at") WHERE "last_seen_at" IS NOT NULL; -- Step 4: Update existing payments with current transaction data -- For payments with transaction_hash, set paid_amount to amount (assuming full payment) -- and set last_seen_at to confirmed_at or createdAt -UPDATE "Payment" -SET +UPDATE "Payment" +SET "paid_amount" = "amount", "last_seen_at" = COALESCE("confirmed_at", "createdAt") -WHERE "transaction_hash" IS NOT NULL +WHERE "transaction_hash" IS NOT NULL AND "paid_amount" = 0; - --- Step 5: Verify the migration -SELECT - 'Migration validation' as operation, - COUNT(*) as total_payments, - COUNT(CASE WHEN "paid_amount" > 0 THEN 1 END) as payments_with_amount, - COUNT(CASE WHEN "last_seen_at" IS NOT NULL THEN 1 END) as payments_with_last_seen, - SUM("paid_amount") as total_paid_amount -FROM "Payment"; diff --git a/fluxapay_backend/prisma/migrations/20260424000003_add_webhook_events_partial_overpay/migration.sql b/fluxapay_backend/prisma/migrations/20260424000003_add_webhook_events_partial_overpay/migration.sql index 9ef228c1..39e6a9e7 100644 --- a/fluxapay_backend/prisma/migrations/20260424000003_add_webhook_events_partial_overpay/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260424000003_add_webhook_events_partial_overpay/migration.sql @@ -1,43 +1,6 @@ -- Migration: Add webhook event types for partial payments and overpayments -- Issue #448: Payment monitor: handle underpayment threshold and timeout rules --- Step 1: Create a backup of existing webhook logs -CREATE TEMPORARY TABLE webhook_logs_backup AS -SELECT * FROM "WebhookLog"; - --- Step 2: Drop the existing WebhookEventType enum (PostgreSQL doesn't support enum alteration) -DROP TYPE IF EXISTS "WebhookEventType"; - --- Step 3: Recreate the WebhookEventType enum with new values -CREATE TYPE "WebhookEventType" AS ENUM( - 'payment_completed', - 'payment_failed', - 'payment_pending', - 'payment_expired', - 'payment_partially_paid', - 'payment_overpaid', - 'refund_completed', - 'refund_failed', - 'settlement_completed', - 'settlement_failed', - 'subscription_created', - 'subscription_cancelled', - 'subscription_renewed' -); - --- Step 4: Update the WebhookLog table to use the new enum type --- This will convert existing values to the new enum type -ALTER TABLE "WebhookLog" -ALTER COLUMN "event_type" TYPE "WebhookEventType" -USING "event_type"::text::"WebhookEventType"; - --- Step 5: Verify the migration -SELECT - 'Migration validation' as operation, - COUNT(*) as total_webhook_logs, - COUNT(CASE WHEN "event_type" = 'payment_partially_paid' THEN 1 END) as partially_paid_events, - COUNT(CASE WHEN "event_type" = 'payment_overpaid' THEN 1 END) as overpaid_events -FROM "WebhookLog"; - --- Clean up the temporary table -DROP TABLE IF EXISTS webhook_logs_backup; +-- Add enum values if they don't already exist (PostgreSQL 9.1+) +ALTER TYPE "WebhookEventType" ADD VALUE IF NOT EXISTS 'payment_partially_paid'; +ALTER TYPE "WebhookEventType" ADD VALUE IF NOT EXISTS 'payment_overpaid'; diff --git a/fluxapay_backend/prisma/migrations/20260625000000_merchant_onchain_registry/migration.sql b/fluxapay_backend/prisma/migrations/20260625000000_merchant_onchain_registry/migration.sql index b80d173f..a7dd07e0 100644 --- a/fluxapay_backend/prisma/migrations/20260625000000_merchant_onchain_registry/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260625000000_merchant_onchain_registry/migration.sql @@ -2,5 +2,5 @@ -- Prevents duplicate Soroban registrations when register_merchant is retried ALTER TABLE "Merchant" - ADD COLUMN "onchain_registered" BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN "onchain_registry_tx_hash" TEXT; + ADD COLUMN IF NOT EXISTS "onchain_registered" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "onchain_registry_tx_hash" TEXT; diff --git a/fluxapay_backend/prisma/migrations/20260626_add_customer_unique/migration.sql b/fluxapay_backend/prisma/migrations/20260626_add_customer_unique/migration.sql new file mode 100644 index 00000000..7f732167 --- /dev/null +++ b/fluxapay_backend/prisma/migrations/20260626_add_customer_unique/migration.sql @@ -0,0 +1,2 @@ +-- Add unique constraint on Customer(merchantId, email) +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_merchantId_email_key" UNIQUE ("merchantId", "email"); diff --git a/fluxapay_backend/prisma/migrations/20260627120000_add_email_suppressions/migration.sql b/fluxapay_backend/prisma/migrations/20260627120000_add_email_suppressions/migration.sql index c78e5f8c..0b774b14 100644 --- a/fluxapay_backend/prisma/migrations/20260627120000_add_email_suppressions/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260627120000_add_email_suppressions/migration.sql @@ -1,5 +1,5 @@ -- CreateTable -CREATE TABLE "EmailSuppression" ( +CREATE TABLE IF NOT EXISTS "EmailSuppression" ( "id" TEXT NOT NULL, "email" TEXT NOT NULL, "reason" TEXT NOT NULL, @@ -11,7 +11,7 @@ CREATE TABLE "EmailSuppression" ( ); -- CreateIndex -CREATE UNIQUE INDEX "EmailSuppression_email_key" ON "EmailSuppression"("email"); +CREATE UNIQUE INDEX IF NOT EXISTS "EmailSuppression_email_key" ON "EmailSuppression"("email"); -- CreateIndex -CREATE INDEX "EmailSuppression_email_idx" ON "EmailSuppression"("email"); +CREATE INDEX IF NOT EXISTS "EmailSuppression_email_idx" ON "EmailSuppression"("email"); diff --git a/fluxapay_backend/prisma/migrations/20260627130000_add_idempotency_record/migration.sql b/fluxapay_backend/prisma/migrations/20260627130000_add_idempotency_record/migration.sql index 08521948..33964b12 100644 --- a/fluxapay_backend/prisma/migrations/20260627130000_add_idempotency_record/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260627130000_add_idempotency_record/migration.sql @@ -1,5 +1,5 @@ -- CreateTable -CREATE TABLE "IdempotencyRecord" ( +CREATE TABLE IF NOT EXISTS "IdempotencyRecord" ( "idempotency_key" TEXT NOT NULL, "user_id" TEXT, "request_hash" TEXT NOT NULL, @@ -12,7 +12,7 @@ CREATE TABLE "IdempotencyRecord" ( ); -- CreateIndex -CREATE INDEX "IdempotencyRecord_user_id_idx" ON "IdempotencyRecord"("user_id"); +CREATE INDEX IF NOT EXISTS "IdempotencyRecord_user_id_idx" ON "IdempotencyRecord"("user_id"); -- CreateIndex -CREATE INDEX "IdempotencyRecord_created_at_idx" ON "IdempotencyRecord"("created_at"); +CREATE INDEX IF NOT EXISTS "IdempotencyRecord_created_at_idx" ON "IdempotencyRecord"("created_at"); diff --git a/fluxapay_backend/prisma/migrations/20260627160000_add_deposit_address_pool/migration.sql b/fluxapay_backend/prisma/migrations/20260627160000_add_deposit_address_pool/migration.sql index 7bd3c137..134291a0 100644 --- a/fluxapay_backend/prisma/migrations/20260627160000_add_deposit_address_pool/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260627160000_add_deposit_address_pool/migration.sql @@ -2,7 +2,7 @@ CREATE TYPE "DepositAddressStatus" AS ENUM ('available', 'assigned', 'cooldown'); -- CreateTable -CREATE TABLE "DepositAddress" ( +CREATE TABLE IF NOT EXISTS "DepositAddress" ( "id" TEXT NOT NULL, "public_key" TEXT NOT NULL, "secret_key" TEXT NOT NULL, @@ -17,16 +17,16 @@ CREATE TABLE "DepositAddress" ( ); -- CreateIndex -CREATE UNIQUE INDEX "DepositAddress_public_key_key" ON "DepositAddress"("public_key"); +CREATE UNIQUE INDEX IF NOT EXISTS "DepositAddress_public_key_key" ON "DepositAddress"("public_key"); -- CreateIndex -CREATE UNIQUE INDEX "DepositAddress_assigned_payment_id_key" ON "DepositAddress"("assigned_payment_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "DepositAddress_assigned_payment_id_key" ON "DepositAddress"("assigned_payment_id"); -- CreateIndex -CREATE INDEX "DepositAddress_status_idx" ON "DepositAddress"("status"); +CREATE INDEX IF NOT EXISTS "DepositAddress_status_idx" ON "DepositAddress"("status"); -- CreateIndex -CREATE INDEX "DepositAddress_cooldown_until_idx" ON "DepositAddress"("cooldown_until"); +CREATE INDEX IF NOT EXISTS "DepositAddress_cooldown_until_idx" ON "DepositAddress"("cooldown_until"); -- AddForeignKey ALTER TABLE "DepositAddress" ADD CONSTRAINT "DepositAddress_assigned_payment_id_fkey" FOREIGN KEY ("assigned_payment_id") REFERENCES "Payment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/fluxapay_backend/prisma/migrations/20260628000000_add_webhooklog_status_composite_index/migration.sql b/fluxapay_backend/prisma/migrations/20260628000000_add_webhooklog_status_composite_index/migration.sql index a33643ae..b6ee965e 100644 --- a/fluxapay_backend/prisma/migrations/20260628000000_add_webhooklog_status_composite_index/migration.sql +++ b/fluxapay_backend/prisma/migrations/20260628000000_add_webhooklog_status_composite_index/migration.sql @@ -2,5 +2,5 @@ -- that also covers created_at ordering, matching the Payment/Invoice pattern. DROP INDEX IF EXISTS "WebhookLog_merchantId_status_idx"; -CREATE INDEX "WebhookLog_merchantId_status_created_at_idx" +CREATE INDEX IF NOT EXISTS "WebhookLog_merchantId_status_created_at_idx" ON "WebhookLog"("merchantId", "status", "created_at" DESC); diff --git a/fluxapay_backend/prisma/migrations/20260723000000_add_payment_status_enum/migration.sql b/fluxapay_backend/prisma/migrations/20260723000000_add_payment_status_enum/migration.sql new file mode 100644 index 00000000..c76d6789 --- /dev/null +++ b/fluxapay_backend/prisma/migrations/20260723000000_add_payment_status_enum/migration.sql @@ -0,0 +1,10 @@ +-- Migration: add_payment_status_enum +-- Adds `refunded` and `partially_refunded` values to the PaymentStatus enum +-- so that Prisma schema, service layer, and API responses use canonical values. +-- Closes #626 – PaymentStatus enum not standardised. + +-- PostgreSQL requires ALTER TYPE … ADD VALUE for enum extensions. +-- These are safe, non-destructive additions; no data migration is needed. + +ALTER TYPE "PaymentStatus" ADD VALUE IF NOT EXISTS 'refunded'; +ALTER TYPE "PaymentStatus" ADD VALUE IF NOT EXISTS 'partially_refunded'; diff --git a/fluxapay_backend/prisma/schema.prisma b/fluxapay_backend/prisma/schema.prisma index b9f30f0c..9cb2d9f5 100644 --- a/fluxapay_backend/prisma/schema.prisma +++ b/fluxapay_backend/prisma/schema.prisma @@ -553,6 +553,8 @@ enum PaymentStatus { paid completed cancelled + refunded + partially_refunded } model CronLock { diff --git a/fluxapay_backend/src/controllers/merchant.controller.ts b/fluxapay_backend/src/controllers/merchant.controller.ts index 97ed6bb2..d3bbd399 100644 --- a/fluxapay_backend/src/controllers/merchant.controller.ts +++ b/fluxapay_backend/src/controllers/merchant.controller.ts @@ -242,7 +242,7 @@ export async function adminUpdateMerchantWebhook(req: Request, res: Response) { const { webhook_url } = req.body as { webhook_url: string }; if (typeof webhook_url !== "string") { - return sendApiError(res, apiError(400, ErrorCode.INVALID_INPUT, "webhook_url must be a string")); + return sendApiError(res, apiError(400, ErrorCode.INVALID_REQUEST_BODY, "webhook_url must be a string")); } const merchant = await adminPrisma.merchant.update({ diff --git a/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts b/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts index d40174dd..d54bf551 100644 --- a/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts +++ b/fluxapay_backend/src/services/__tests__/merchantDeletion.service.test.ts @@ -7,6 +7,12 @@ jest.mock("../audit.service", () => ({ logChargesCancelled: jest.fn().mockResolvedValue({}), })); +// Mock Cloudinary service so unit tests don't make real HTTP calls. +const mockDeleteFromCloudinary = jest.fn().mockResolvedValue(undefined); +jest.mock("../cloudinary.service", () => ({ + deleteFromCloudinary: (...args: unknown[]) => mockDeleteFromCloudinary(...args), +})); + const merchant = { findUnique: jest.fn(), update: jest.fn() }; const merchantDeletionRequest = { upsert: jest.fn(), @@ -14,7 +20,7 @@ const merchantDeletionRequest = { update: jest.fn(), }; const merchantKYC = { updateMany: jest.fn() }; -const kYCDocument = { deleteMany: jest.fn() }; +const kYCDocument = { findMany: jest.fn(), deleteMany: jest.fn() }; const webhookLog = { updateMany: jest.fn() }; const oTP = { deleteMany: jest.fn() }; const bankAccount = { deleteMany: jest.fn() }; @@ -104,6 +110,8 @@ describe("executeDeletion", () => { }); merchant.update.mockResolvedValue({}); merchantKYC.updateMany.mockResolvedValue({}); + // Default: no KYC docs — override per-test to simulate documents. + kYCDocument.findMany.mockResolvedValue([]); kYCDocument.deleteMany.mockResolvedValue({}); webhookLog.updateMany.mockResolvedValue({ count: 2 }); oTP.deleteMany.mockResolvedValue({}); @@ -114,6 +122,7 @@ describe("executeDeletion", () => { merchantDeletionRequest.update.mockResolvedValue({}); apiKey.updateMany.mockResolvedValue({ count: 3 }); payment.updateMany.mockResolvedValue({ count: 1 }); + mockDeleteFromCloudinary.mockResolvedValue(undefined); }); it("revokes API keys, cancels webhooks/charges, and emits audit events", async () => { @@ -180,3 +189,79 @@ describe("getDeletionRequest", () => { await expect(getDeletionRequest(MERCHANT_ID)).rejects.toMatchObject({ status: 404 }); }); }); + +// ─── Issue #720: Cloudinary KYC document purge on merchant deletion ─────────── + +describe("executeDeletion — Cloudinary KYC purge (#720)", () => { + beforeEach(() => { + merchant.findUnique.mockResolvedValue(activeMerchant); + merchantDeletionRequest.findUnique.mockResolvedValue({ + id: "req-1", + merchantId: MERCHANT_ID, + reason: "gdpr request", + }); + merchant.update.mockResolvedValue({}); + merchantKYC.updateMany.mockResolvedValue({}); + kYCDocument.deleteMany.mockResolvedValue({}); + webhookLog.updateMany.mockResolvedValue({ count: 0 }); + oTP.deleteMany.mockResolvedValue({}); + bankAccount.deleteMany.mockResolvedValue({}); + merchantSubscription.deleteMany.mockResolvedValue({}); + customer.deleteMany.mockResolvedValue({}); + refreshToken.deleteMany.mockResolvedValue({}); + merchantDeletionRequest.update.mockResolvedValue({}); + apiKey.updateMany.mockResolvedValue({ count: 0 }); + payment.updateMany.mockResolvedValue({ count: 0 }); + }); + + it("calls deleteFromCloudinary for each KYC document public_id before deleting DB rows", async () => { + const docs = [ + { id: "doc-1", public_id: "kyc-documents/merchant-1-passport" }, + { id: "doc-2", public_id: "kyc-documents/merchant-1-address-proof" }, + ]; + kYCDocument.findMany.mockResolvedValue(docs); + mockDeleteFromCloudinary.mockResolvedValue(undefined); + + await executeDeletion(MERCHANT_ID, ADMIN_ID); + + expect(kYCDocument.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { kyc: { merchantId: MERCHANT_ID } }, + select: { id: true, public_id: true }, + }), + ); + + // One call per document + expect(mockDeleteFromCloudinary).toHaveBeenCalledTimes(docs.length); + expect(mockDeleteFromCloudinary).toHaveBeenCalledWith(docs[0]!.public_id); + expect(mockDeleteFromCloudinary).toHaveBeenCalledWith(docs[1]!.public_id); + + // DB rows are deleted regardless of Cloudinary outcome + expect(kYCDocument.deleteMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { kyc: { merchantId: MERCHANT_ID } } }), + ); + }); + + it("still deletes DB rows and completes deletion even when Cloudinary purge fails", async () => { + const docs = [{ id: "doc-1", public_id: "kyc-documents/merchant-1-passport" }]; + kYCDocument.findMany.mockResolvedValue(docs); + // Simulate a Cloudinary API error + mockDeleteFromCloudinary.mockRejectedValue(new Error("Cloudinary 503 Service Unavailable")); + + // Deletion must not throw — Cloudinary failure is logged but non-fatal + await expect(executeDeletion(MERCHANT_ID, ADMIN_ID)).resolves.toBeUndefined(); + + // DB rows are still deleted so PII is removed + expect(kYCDocument.deleteMany).toHaveBeenCalled(); + }); + + it("skips Cloudinary calls and DB delete when merchant has no KYC documents", async () => { + kYCDocument.findMany.mockResolvedValue([]); + + await executeDeletion(MERCHANT_ID, ADMIN_ID); + + expect(mockDeleteFromCloudinary).not.toHaveBeenCalled(); + // deleteMany is still called (no-op is fine; it won't error) + expect(kYCDocument.deleteMany).toHaveBeenCalled(); + }); +}); diff --git a/fluxapay_backend/src/services/merchantDeletion.service.ts b/fluxapay_backend/src/services/merchantDeletion.service.ts index c6fb7efe..2640b75d 100644 --- a/fluxapay_backend/src/services/merchantDeletion.service.ts +++ b/fluxapay_backend/src/services/merchantDeletion.service.ts @@ -9,7 +9,7 @@ import { ErrorCode } from "../types/errors"; * - PII fields on Merchant are overwritten with anonymized placeholders. * - Active API keys are revoked; webhook endpoints deactivated. * - Pending webhook deliveries are cancelled; active charges cancelled. - * - KYC documents are deleted; KYC record is anonymized. + * - KYC documents are purged from Cloudinary then deleted from DB; KYC record is anonymized. * - OTPs, BankAccount, Customers, Subscriptions are hard-deleted. */ import { PrismaClient } from "../generated/client/client"; @@ -19,6 +19,10 @@ import { logWebhooksDeactivated, logChargesCancelled, } from "./audit.service"; +import { deleteFromCloudinary } from "./cloudinary.service"; +import { getLogger } from "../utils/logger"; + +const logger = getLogger(); const prisma = new PrismaClient(); @@ -143,7 +147,30 @@ export async function executeDeletion( }, }); - // 6. Delete KYC documents + // 6. Purge KYC document files from Cloudinary, then delete DB records. + // Query public_ids BEFORE the transaction deletes the rows. + const kycDocs = await tx.kYCDocument.findMany({ + where: { kyc: { merchantId } }, + select: { id: true, public_id: true }, + }); + + // Purge each file from Cloudinary; log failures so operators can reconcile. + const cloudinaryResults = await Promise.allSettled( + kycDocs.map((doc) => deleteFromCloudinary(doc.public_id)), + ); + cloudinaryResults.forEach((result, idx) => { + if (result.status === "rejected") { + logger.error("ALERT: Cloudinary KYC document purge failed — manual reconciliation required", { + event: "cloudinary_purge_failed", + merchantId, + docId: kycDocs[idx]?.id, + public_id: kycDocs[idx]?.public_id, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + }); + + // Delete the DB rows regardless of Cloudinary outcome (PII must be removed). await tx.kYCDocument.deleteMany({ where: { kyc: { merchantId } } }); // 7. Clear webhook log endpoint URLs (may contain PII in query params) diff --git a/fluxapay_backend/src/types/payment.ts b/fluxapay_backend/src/types/payment.ts index e1b770d9..2eb2811b 100644 --- a/fluxapay_backend/src/types/payment.ts +++ b/fluxapay_backend/src/types/payment.ts @@ -11,6 +11,9 @@ export enum PaymentStatus { FAILED = 'failed', PAID = 'paid', COMPLETED = 'completed', + CANCELLED = 'cancelled', + REFUNDED = 'refunded', + PARTIALLY_REFUNDED = 'partially_refunded', } /** @@ -41,6 +44,9 @@ export function getTerminalPaymentStatuses(): PaymentStatus[] { PaymentStatus.FAILED, PaymentStatus.PAID, PaymentStatus.COMPLETED, + PaymentStatus.REFUNDED, + PaymentStatus.PARTIALLY_REFUNDED, + PaymentStatus.CANCELLED, ]; } diff --git a/fluxapay_frontend/e2e/critical-flows.spec.ts b/fluxapay_frontend/e2e/critical-flows.spec.ts new file mode 100644 index 00000000..c382a81a --- /dev/null +++ b/fluxapay_frontend/e2e/critical-flows.spec.ts @@ -0,0 +1,392 @@ +/** + * Critical-path E2E tests — Issue #595 + * Covers: + * 1. Customer completes payment via QR code checkout + * 2. Merchant logs in and views the payment list + * 3. Merchant creates an invoice and copies its payment link + * 4. Merchant initiates a refund from the payment details page + * + * All tests run in mocked mode by default (no live backend required). + * Set E2E_MODE=real + E2E_BASE_URL + E2E_API_URL to run against a seeded env. + */ + +import { test, expect } from "@playwright/test"; +import { loginAndNavigate } from "./helpers/dashboard"; +import { setupMocks } from "./helpers/mocks"; + +// ─── Shared mock data ──────────────────────────────────────────────────────── + +const MERCHANT_ID = "mer_e2e_critical"; +const PAYMENT_ID = "pay_e2e_001"; +const REFUND_PAYMENT_ID = "pay_e2e_confirmed_001"; + +const mockPendingPayment = { + id: PAYMENT_ID, + amount: 50, + currency: "USD", + status: "pending", + merchantName: "E2E Merchant", + address: "GTEST123ABCSTELLARADDR001", + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + successUrl: null, + memo: "PAY-E2E-001", + memoType: "text", + memoRequired: true, +}; + +const mockConfirmedPayment = { + id: REFUND_PAYMENT_ID, + merchantId: MERCHANT_ID, + amount: 100, + currency: "USD", + status: "confirmed", + customer_email: "customer@example.com", + description: "E2E confirmed payment for refund", + createdAt: new Date().toISOString(), + stellar_address: "GTEST456DEFSTELLARADDR001", +}; + +// ─── 1. Customer checkout via QR code ──────────────────────────────────────── + +test.describe("Customer payment checkout", () => { + test("customer sees QR code and address for a pending payment", async ({ page }) => { + await page.route(`**/api/payments/${PAYMENT_ID}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mockPendingPayment), + }), + ); + + await page.route(`**/api/payments/${PAYMENT_ID}/status`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "pending" }), + }), + ); + + // SSE stream — respond with empty/closed stream to fall back to polling + await page.route(`**/api/payments/${PAYMENT_ID}/stream`, (route) => + route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: "", + }), + ); + + await page.goto(`/pay/${PAYMENT_ID}`); + + // QR code should appear (either via role=img alt text or canvas) + await expect( + page + .getByRole("img", { name: /qr code/i }) + .or(page.getByTestId("qr-canvas")), + ).toBeVisible({ timeout: 8000 }); + + // Deposit address should be displayed + await expect(page.getByText(mockPendingPayment.address)).toBeVisible({ + timeout: 5000, + }); + + // Memo should be required + await expect(page.getByText(/memo required/i)).toBeVisible({ timeout: 5000 }); + }); + + test("customer sees confirmed state when payment is paid", async ({ page }) => { + await page.route(`**/api/payments/${PAYMENT_ID}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...mockPendingPayment, status: "confirmed" }), + }), + ); + + await page.goto(`/pay/${PAYMENT_ID}`); + + await expect( + page.getByText(/payment confirmed/i), + ).toBeVisible({ timeout: 8000 }); + }); + + test("QR code and copy field update when deposit address changes via polling", async ({ + page, + }) => { + const initialAddress = "GINITIALADDR001STELLAR"; + const updatedAddress = "GUPDATEDADDR002STELLAR"; + let callCount = 0; + + await page.route(`**/api/payments/${PAYMENT_ID}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...mockPendingPayment, address: initialAddress }), + }), + ); + + // First poll returns same status; second returns new address + await page.route(`**/api/payments/${PAYMENT_ID}/status`, (route) => { + callCount += 1; + const address = callCount >= 2 ? updatedAddress : initialAddress; + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "pending", address }), + }); + }); + + await page.route(`**/api/payments/${PAYMENT_ID}/stream`, (route) => + route.fulfill({ status: 200, contentType: "text/event-stream", body: "" }), + ); + + await page.goto(`/pay/${PAYMENT_ID}`); + + // Initial address shown + await expect(page.getByText(initialAddress)).toBeVisible({ timeout: 8000 }); + + // Wait for address update toast + await expect(page.getByText(/address updated/i)).toBeVisible({ + timeout: 15000, + }); + + // Updated address now shown + await expect(page.getByText(updatedAddress)).toBeVisible({ timeout: 5000 }); + }); +}); + +// ─── 2. Merchant logs in and views payment list ─────────────────────────────── + +test.describe("Merchant payment dashboard", () => { + const mockPayments = [ + { + id: "pay_001", + amount: 100, + currency: "USD", + status: "confirmed", + customer_email: "alice@example.com", + description: "Order #1", + createdAt: new Date().toISOString(), + }, + { + id: "pay_002", + amount: 250, + currency: "USD", + status: "pending", + customer_email: "bob@example.com", + description: "Order #2", + createdAt: new Date().toISOString(), + }, + ]; + + test("merchant logs in and sees payment list", async ({ page }) => { + await setupMocks(page, async (p) => { + await p.route("**/api/v1/payments*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { payments: mockPayments }, + meta: { page: 1, limit: 20, total: mockPayments.length }, + }), + }); + }); + }); + + await loginAndNavigate(page, "/dashboard/payments"); + + // Payments heading should be visible + await expect( + page.getByRole("heading", { name: /payments/i }), + ).toBeVisible({ timeout: 10000 }); + + // At least one payment row visible + await expect(page.getByText("alice@example.com")).toBeVisible({ + timeout: 8000, + }); + await expect(page.getByText("bob@example.com")).toBeVisible({ + timeout: 8000, + }); + }); +}); + +// ─── 3. Merchant creates invoice and copies payment link ────────────────────── +// (Covered thoroughly in invoices.spec.ts — this test verifies the critical path +// from the consolidated critical-flows file.) + +test.describe("Invoice creation — critical path", () => { + const mockInvoices: { + id: string; + invoice_number: string; + customer_email: string; + amount: number; + currency: string; + due_date: string; + status: string; + payment_link: string; + created_at: string; + }[] = []; + + test("merchant creates invoice and payment link is available to copy", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + const customerEmail = `critical-${Date.now()}@example.com`; + + await setupMocks(page, async (p) => { + await p.route("**/api/v1/invoices*", async (route) => { + const method = route.request().method(); + if (method === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { invoices: mockInvoices }, + meta: { page: 1, limit: 20, total: mockInvoices.length }, + }), + }); + } else if (method === "POST") { + const body = route.request().postDataJSON(); + const newInvoice = { + id: `inv_critical_${Date.now()}`, + invoice_number: `INV-CRIT-001`, + customer_email: body.customer_email ?? customerEmail, + amount: body.amount ?? 200, + currency: body.currency ?? "USD", + due_date: body.due_date ?? new Date().toISOString(), + status: "pending", + payment_link: `http://localhost:3075/pay/invoice/inv_critical_${Date.now()}`, + created_at: new Date().toISOString(), + }; + mockInvoices.unshift(newInvoice); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ message: "Invoice created", invoice: newInvoice }), + }); + } else { + await route.continue(); + } + }); + }); + + await loginAndNavigate(page, "/dashboard/invoices"); + + await expect( + page.getByRole("heading", { name: "Invoices", exact: true }), + ).toBeVisible({ timeout: 10000 }); + + await page.getByRole("button", { name: /create invoice/i }).click(); + + // Fill form + await page.getByPlaceholder("Jane Doe").fill("Critical Test Client"); + await page.getByPlaceholder("jane@example.com").fill(customerEmail); + + const today = new Date(); + const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + await page.locator('input[type="date"]').fill(formattedDate); + + await page.getByPlaceholder("Description").fill("Critical E2E Service"); + await page.getByPlaceholder("Qty").fill("2"); + await page.getByPlaceholder("Unit price").fill("100"); + + await page.getByRole("button", { name: /^create invoice$/i }).click(); + + // Invoice row should appear + const tableRow = page.getByRole("row").filter({ hasText: customerEmail }); + await expect(tableRow).toBeVisible({ timeout: 10000 }); + + // Copy payment link + const copyButton = tableRow.getByTitle("Copy Payment Link"); + await copyButton.click(); + + const clipboardContent = await page.evaluate(() => + navigator.clipboard.readText(), + ); + expect(clipboardContent).toContain("/pay/invoice/"); + }); +}); + +// ─── 4. Merchant initiates refund from payment details ──────────────────────── + +test.describe("Refund initiation", () => { + test("merchant can initiate a refund from the payment details page", async ({ + page, + }) => { + await setupMocks(page, async (p) => { + // Payment detail route + await p.route(`**/api/v1/payments/${REFUND_PAYMENT_ID}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ payment: mockConfirmedPayment }), + }); + }); + + // Refund creation route + await p.route( + `**/api/v1/payments/${REFUND_PAYMENT_ID}/refunds`, + async (route) => { + if (route.request().method() !== "POST") return route.continue(); + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + message: "Refund initiated", + refund: { + id: "ref_e2e_001", + paymentId: REFUND_PAYMENT_ID, + amount: 100, + status: "pending", + }, + }), + }); + }, + ); + + // Refund list route (used after initiation) + await p.route( + `**/api/v1/payments/${REFUND_PAYMENT_ID}/refunds`, + async (route) => { + if (route.request().method() !== "GET") return route.continue(); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ refunds: [] }), + }); + }, + ); + }); + + await loginAndNavigate( + page, + `/dashboard/payments/${REFUND_PAYMENT_ID}`, + ); + + // Refund button should be visible on a confirmed payment + const refundButton = page.getByRole("button", { name: /refund/i }).first(); + await expect(refundButton).toBeVisible({ timeout: 10000 }); + + await refundButton.click(); + + // Confirm dialog / modal should appear + const confirmButton = page + .getByRole("button", { name: /confirm refund/i }) + .or(page.getByRole("button", { name: /submit refund/i })) + .or(page.getByRole("button", { name: /proceed/i })); + + // If a confirmation dialog appears, click it + if (await confirmButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await confirmButton.click(); + } + + // Success indicator: toast or status update + await expect( + page + .getByText(/refund initiated/i) + .or(page.getByText(/refund.*pending/i)) + .or(page.getByText(/refund.*submitted/i)), + ).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/fluxapay_frontend/e2e/helpers/dashboard.ts b/fluxapay_frontend/e2e/helpers/dashboard.ts index cc09b4ed..431bf747 100644 --- a/fluxapay_frontend/e2e/helpers/dashboard.ts +++ b/fluxapay_frontend/e2e/helpers/dashboard.ts @@ -1,5 +1,5 @@ import { Page, expect } from "@playwright/test"; -import { getTestEmail, getTestPassword, isRealMode } from "./mode"; +import { getTestEmail, getTestPassword } from "./mode"; import { setupMocks } from "./mocks"; const CP_MERCHANT_ID = "mer_e2e_critical"; diff --git a/fluxapay_frontend/e2e/invoices.spec.ts b/fluxapay_frontend/e2e/invoices.spec.ts index dad64093..f7ccf841 100644 --- a/fluxapay_frontend/e2e/invoices.spec.ts +++ b/fluxapay_frontend/e2e/invoices.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from "@playwright/test"; import { loginAndNavigate } from "./helpers/dashboard"; -import { isRealMode } from "./helpers/mode"; import { setupMocks } from "./helpers/mocks"; test.describe("Invoice creation flow", () => { diff --git a/fluxapay_frontend/playwright.config.ts b/fluxapay_frontend/playwright.config.ts index f7bb7fb7..6b7e47b2 100644 --- a/fluxapay_frontend/playwright.config.ts +++ b/fluxapay_frontend/playwright.config.ts @@ -5,13 +5,17 @@ export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, + /** Retry twice in CI to reduce flakiness; no retries locally. */ retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? 'github' : 'list', + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', use: { baseURL: getBaseUrl(), trace: 'on-first-retry', + /** Capture a screenshot whenever a test fails. */ + screenshot: 'only-on-failure', + video: 'retain-on-failure', }, projects: [ diff --git a/fluxapay_frontend/src/__tests__/components/LoginForm.test.tsx b/fluxapay_frontend/src/__tests__/components/LoginForm.test.tsx index d0025319..5c9469f0 100644 --- a/fluxapay_frontend/src/__tests__/components/LoginForm.test.tsx +++ b/fluxapay_frontend/src/__tests__/components/LoginForm.test.tsx @@ -1,7 +1,6 @@ /** * Component tests for LoginForm */ -/* eslint-disable @next/next/no-img-element */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { LoginForm } from '@/features/auth'; diff --git a/fluxapay_frontend/src/__tests__/components/SignUpForm.test.tsx b/fluxapay_frontend/src/__tests__/components/SignUpForm.test.tsx index ba51fab7..53be9474 100644 --- a/fluxapay_frontend/src/__tests__/components/SignUpForm.test.tsx +++ b/fluxapay_frontend/src/__tests__/components/SignUpForm.test.tsx @@ -1,7 +1,6 @@ /** * Component tests for SignUpForm */ -/* eslint-disable @next/next/no-img-element */ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SignUpForm } from '@/features/auth'; diff --git a/fluxapay_frontend/src/__tests__/setup.tsx b/fluxapay_frontend/src/__tests__/setup.tsx index 9614e1d7..45aad39d 100644 --- a/fluxapay_frontend/src/__tests__/setup.tsx +++ b/fluxapay_frontend/src/__tests__/setup.tsx @@ -32,7 +32,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@/i18n/routing', () => ({ - Link: ({ children, href }: { children: React.ReactNode; href: string }) => children, + Link: ({ children }: { children: React.ReactNode; href: string }) => children, useRouter: () => ({ push: vi.fn(), replace: vi.fn(), @@ -79,6 +79,6 @@ vi.mock('next/image', () => ({ __esModule: true, default: (props: React.ImgHTMLAttributes) => { // eslint-disable-next-line @next/next/no-img-element - return ; + return ; }, })); diff --git a/fluxapay_frontend/src/app/[locale]/dashboard/developers/page.tsx b/fluxapay_frontend/src/app/[locale]/dashboard/developers/page.tsx index 52e3f148..9a01fe18 100644 --- a/fluxapay_frontend/src/app/[locale]/dashboard/developers/page.tsx +++ b/fluxapay_frontend/src/app/[locale]/dashboard/developers/page.tsx @@ -415,16 +415,17 @@ function CreateApiKeyModal({ isOpen, onClose, onCreateSuccess, + existingNames, }: { isOpen: boolean; onClose: () => void; onCreateSuccess: (key: { id: string; name: string; masked: string; secret: string }) => void; + existingNames: Set; }) { const [name, setName] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [createdSecret, setCreatedSecret] = useState(null); - const [existingNames, setExistingNames] = useState>(new Set()); const [copied, setCopied] = useState(null); useEffect(() => { @@ -1354,6 +1355,7 @@ export default function DevelopersPage() { setShowCreateKeyModal(false)} + existingNames={new Set(apiKeys.map((k) => k.name.toLowerCase()))} onCreateSuccess={(key) => { setApiKeys([...apiKeys, { ...key, createdAt: new Date().toLocaleDateString() }]); setShowCreateKeyModal(false); diff --git a/fluxapay_frontend/src/app/[locale]/dashboard/payments/[id]/page.tsx b/fluxapay_frontend/src/app/[locale]/dashboard/payments/[id]/page.tsx index 869625c3..e8bf2f47 100644 --- a/fluxapay_frontend/src/app/[locale]/dashboard/payments/[id]/page.tsx +++ b/fluxapay_frontend/src/app/[locale]/dashboard/payments/[id]/page.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { api, ApiError, InitiateRefundRequest } from "@/lib/api"; import { PaymentDetails } from "@/features/dashboard/payments/PaymentDetails"; import { type Payment } from "@/features/dashboard/payments/types"; -import { type RefundRecord, type RefundReason } from "@/features/dashboard/refunds/refunds-mock"; +import { type RefundRecord } from "@/features/dashboard/refunds/refunds-mock"; import { Button } from "@/components/Button"; import { ChevronLeft, Loader2, RefreshCw } from "lucide-react"; import toast from "react-hot-toast"; @@ -54,7 +54,7 @@ export default function PaymentDetailsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const fetchPaymentDetails = async () => { + const fetchPaymentDetails = useCallback(async () => { setLoading(true); setError(null); try { @@ -76,11 +76,11 @@ export default function PaymentDetailsPage() { } finally { setLoading(false); } - }; + }, [id]); useEffect(() => { if (id) fetchPaymentDetails(); - }, [id]); + }, [id, fetchPaymentDetails]); const handleInitiateRefund = async (payload: InitiateRefundRequest) => { try { diff --git a/fluxapay_frontend/src/app/[locale]/dashboard/payments/page.tsx b/fluxapay_frontend/src/app/[locale]/dashboard/payments/page.tsx index deec19f9..863ea1cd 100644 --- a/fluxapay_frontend/src/app/[locale]/dashboard/payments/page.tsx +++ b/fluxapay_frontend/src/app/[locale]/dashboard/payments/page.tsx @@ -1,19 +1,15 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { PaymentsTable } from "@/features/dashboard/payments/PaymentsTable"; import { PaymentsFilters } from "@/features/dashboard/payments/PaymentsFilters"; import { type Payment } from "@/features/dashboard/payments/types"; import { PaymentDrawer } from "@/features/dashboard/payments/PaymentDrawer"; import { usePaymentUpdates } from "@/hooks/usePaymentUpdates"; -import { - type RefundRecord, - type RefundReason, -} from "@/features/dashboard/refunds/refunds-mock"; import { Modal } from "@/components/Modal"; import { Button } from "@/components/Button"; -import { Download, Plus, Wifi, WifiOff } from "lucide-react"; +import { Plus, Wifi, WifiOff } from "lucide-react"; import { Suspense } from "react"; import toast from "react-hot-toast"; import { api, type MerchantExportFormat } from "@/lib/api"; @@ -44,20 +40,6 @@ interface BackendPayment { fiat_currency?: string; } -interface BackendRefund { - id: string; - payment_id: string; - merchant_id: string; - amount: number; - currency: "USDC" | "XLM"; - customer_address: string; - reason: RefundReason; - reason_note?: string; - status: RefundRecord["status"]; - stellar_tx_hash?: string; - created_at: string; -} - function mapBackendPayment(p: BackendPayment): Payment { return { id: p.id, @@ -81,22 +63,6 @@ function mapBackendPayment(p: BackendPayment): Payment { }; } -function mapBackendRefund(refund: BackendRefund): RefundRecord { - return { - id: refund.id, - paymentId: refund.payment_id, - merchantId: refund.merchant_id, - amount: refund.amount, - currency: refund.currency, - customerAddress: refund.customer_address, - reason: refund.reason, - reasonNote: refund.reason_note, - status: refund.status, - stellarTxHash: refund.stellar_tx_hash, - createdAt: refund.created_at, - }; -} - function PaymentsContent() { const router = useRouter(); const searchParams = useSearchParams(); diff --git a/fluxapay_frontend/src/app/[locale]/docs/getting-started/page.tsx b/fluxapay_frontend/src/app/[locale]/docs/getting-started/page.tsx index 03b3583e..61e8d67f 100644 --- a/fluxapay_frontend/src/app/[locale]/docs/getting-started/page.tsx +++ b/fluxapay_frontend/src/app/[locale]/docs/getting-started/page.tsx @@ -4,7 +4,7 @@ import { DocsLayout } from "@/components/docs/DocsLayout"; import { CodeBlock } from "@/components/docs/CodeBlock"; import { EditOnGitHub } from "@/components/docs/EditOnGitHub"; import { generatePageMetadata } from "@/lib/seo"; -import { CheckCircle, ArrowRight, Key, Shield, Zap, Globe } from "lucide-react"; +import { ArrowRight, Key, Shield, Zap, Globe } from "lucide-react"; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params; diff --git a/fluxapay_frontend/src/app/admin/config/page.tsx b/fluxapay_frontend/src/app/admin/config/page.tsx index 44511d7b..c86141fa 100644 --- a/fluxapay_frontend/src/app/admin/config/page.tsx +++ b/fluxapay_frontend/src/app/admin/config/page.tsx @@ -99,6 +99,7 @@ const AdminConfigPage = () => { useEffect(() => { setOriginalConfig(config); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -179,7 +180,7 @@ const AdminConfigPage = () => { }, 1500); }); toast.success("Configuration saved successfully"); - } catch (error) { + } catch { setOriginalConfig(previousConfig); setIsDirty(true); toast.error("Failed to save configuration. Changes have been reverted."); @@ -195,15 +196,6 @@ const AdminConfigPage = () => { toast.success("Changes discarded"); }; - const handleNavigation = (path: string) => { - if (isDirty) { - pendingNavigationRef.current = path; - setShowUnsavedDialog(true); - } else { - router.push(path); - } - }; - const handleLeavePage = () => { setShowUnsavedDialog(false); if (pendingNavigationRef.current) { diff --git a/fluxapay_frontend/src/app/admin/merchants/page.tsx b/fluxapay_frontend/src/app/admin/merchants/page.tsx index 7f8cc4cd..61991360 100644 --- a/fluxapay_frontend/src/app/admin/merchants/page.tsx +++ b/fluxapay_frontend/src/app/admin/merchants/page.tsx @@ -187,7 +187,11 @@ const AdminMerchantsPage = () => { const toggleSelectOne = (id: string) => { setSelectedIds(prev => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } return next; }); }; diff --git a/fluxapay_frontend/src/app/admin/overview/AddressPoolCard.tsx b/fluxapay_frontend/src/app/admin/overview/AddressPoolCard.tsx index 39a6db36..12631feb 100644 --- a/fluxapay_frontend/src/app/admin/overview/AddressPoolCard.tsx +++ b/fluxapay_frontend/src/app/admin/overview/AddressPoolCard.tsx @@ -44,14 +44,11 @@ export function AddressPoolCard() { const utilizationPercentage = total > 0 ? (assigned / total) * 100 : 0; let statusColor = "text-green-500"; - let bgStatusColor = "bg-green-500"; if (availablePercentage < 10) { statusColor = "text-red-500"; - bgStatusColor = "bg-red-500"; } else if (availablePercentage <= 30) { statusColor = "text-amber-500"; - bgStatusColor = "bg-amber-500"; } return ( diff --git a/fluxapay_frontend/src/app/admin/webhooks/page.tsx b/fluxapay_frontend/src/app/admin/webhooks/page.tsx index e40e6a77..ece84048 100644 --- a/fluxapay_frontend/src/app/admin/webhooks/page.tsx +++ b/fluxapay_frontend/src/app/admin/webhooks/page.tsx @@ -69,8 +69,8 @@ export default function WebhooksPage() { await api.admin.webhooks.retry(logId); toast.success(`Retry initiated for webhook ${logId}`, { id: logId }); mutate(); - } catch (err: any) { - toast.error(err.message || "Failed to retry webhook", { id: logId }); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to retry webhook", { id: logId }); } }, [mutate]); @@ -82,8 +82,8 @@ export default function WebhooksPage() { await api.admin.merchants.disableWebhook(merchantId); toast.success(`Webhooks disabled for ${merchantName || merchantId}`, { id: toastId }); mutate(); - } catch (err: any) { - toast.error(err.message || "Failed to disable webhooks"); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to disable webhooks"); } }, [mutate]); diff --git a/fluxapay_frontend/src/app/checkout/[charge_id]/page.tsx b/fluxapay_frontend/src/app/checkout/[charge_id]/page.tsx index c9b4a6df..6d26a126 100644 --- a/fluxapay_frontend/src/app/checkout/[charge_id]/page.tsx +++ b/fluxapay_frontend/src/app/checkout/[charge_id]/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { Loader2, XCircle, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'; +import toast from 'react-hot-toast'; import { usePaymentStatus } from '@/hooks/usePaymentStatus'; import { TxHashLink } from '@/components/TxHashLink'; import { PaymentQRCode } from '@/components/checkout/PaymentQRCode'; @@ -29,9 +30,20 @@ export default function CheckoutPage() { const searchParams = useSearchParams(); const chargeId = params.charge_id as string; - const { payment, loading, error, isOffline, retryConnection } = + const { payment, loading, error, isOffline, retryConnection, depositAddressUpdated } = usePaymentStatus(chargeId); + // Notify the customer when the deposit address has changed (e.g. after timeout reset). + useEffect(() => { + if (depositAddressUpdated) { + toast('Address updated — please use the new address shown below.', { + icon: '🔄', + duration: 5000, + id: 'address-updated', + }); + } + }, [depositAddressUpdated]); + // Customization from query params (SDK overrides) const qAccent = searchParams.get('accentColor') || searchParams.get('primaryColor'); const qLogo = searchParams.get('logoUrl'); diff --git a/fluxapay_frontend/src/app/pay/[payment_id]/page.tsx b/fluxapay_frontend/src/app/pay/[payment_id]/page.tsx index ccf71c51..a47506bd 100644 --- a/fluxapay_frontend/src/app/pay/[payment_id]/page.tsx +++ b/fluxapay_frontend/src/app/pay/[payment_id]/page.tsx @@ -4,14 +4,14 @@ import { useEffect } from 'react'; import Link from 'next/link'; import { useParams, useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { Loader2, XCircle, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'; +import { Loader2, XCircle, CheckCircle, AlertCircle } from 'lucide-react'; +import toast from 'react-hot-toast'; import { usePaymentStatus } from '@/hooks/usePaymentStatus'; import { useOfflineSync } from '@/hooks/useOfflineSync'; import { TxHashLink } from '@/components/TxHashLink'; import { PaymentQRCode } from '@/components/checkout/PaymentQRCode'; import { PaymentTimer } from '@/components/checkout/PaymentTimer'; import { PaymentStatus } from '@/components/checkout/PaymentStatus'; -import { StellarPayButton } from '@/components/checkout/StellarPayButton'; import { BrowserWalletButtons } from '@/components/checkout/BrowserWalletButtons'; import { LanguageSwitcher } from '@/components/LanguageSwitcher'; import { @@ -33,9 +33,20 @@ export default function CheckoutPage() { const searchParams = useSearchParams(); const paymentId = params.payment_id as string; - const { payment, loading, error, isOffline, retryConnection } = + const { payment, loading, error, isOffline, retryConnection, depositAddressUpdated } = usePaymentStatus(paymentId); + // Notify the customer when the deposit address has changed (e.g. after timeout reset). + useEffect(() => { + if (depositAddressUpdated) { + toast('Address updated — please use the new address shown below.', { + icon: '🔄', + duration: 5000, + id: 'address-updated', + }); + } + }, [depositAddressUpdated]); + const { pendingCount, queueAction } = useOfflineSync(paymentId, retryConnection); // Customization from query params (SDK overrides) diff --git a/fluxapay_frontend/src/components/CommandPalette.tsx b/fluxapay_frontend/src/components/CommandPalette.tsx index fb75e551..cd0e3397 100644 --- a/fluxapay_frontend/src/components/CommandPalette.tsx +++ b/fluxapay_frontend/src/components/CommandPalette.tsx @@ -30,7 +30,6 @@ export function CommandPalette() { const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const [isAdminUser, setIsAdminUser] = useState(false); - const [recentSearches, setRecentSearches] = useState([]); const inputRef = useRef(null); const listRef = useRef(null); const dialogRef = useRef(null); @@ -38,13 +37,9 @@ export function CommandPalette() { const routes = isAdminUser ? [...BASE_ROUTES, ...ADMIN_ROUTES] : BASE_ROUTES; - // Initialize admin status and recent searches + // Initialize admin status useEffect(() => { setIsAdminUser(isAdmin()); - const saved = sessionStorage.getItem("commandPaletteSearches"); - if (saved) { - setRecentSearches(JSON.parse(saved)); - } }, []); const filtered = routes.filter((r) => @@ -59,11 +54,12 @@ export function CommandPalette() { const saveSearch = useCallback((searchQuery: string) => { if (!searchQuery.trim()) return; - setRecentSearches((prev) => { - const updated = [searchQuery, ...prev.filter((s) => s !== searchQuery)].slice(0, 10); - sessionStorage.setItem("commandPaletteSearches", JSON.stringify(updated)); - return updated; - }); + const prev: string[] = (() => { + try { return JSON.parse(sessionStorage.getItem("commandPaletteSearches") ?? "[]"); } + catch { return []; } + })(); + const updated = [searchQuery, ...prev.filter((s) => s !== searchQuery)].slice(0, 10); + sessionStorage.setItem("commandPaletteSearches", JSON.stringify(updated)); }, []); const navigate = useCallback( @@ -91,6 +87,7 @@ export function CommandPalette() { // Focus trap implementation useEffect(() => { if (!open || !dialogRef.current) return; + const dialog = dialogRef.current; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -98,8 +95,8 @@ export function CommandPalette() { } }; - dialogRef.current.addEventListener("keydown", handleKeyDown); - return () => dialogRef.current?.removeEventListener("keydown", handleKeyDown); + dialog.addEventListener("keydown", handleKeyDown); + return () => dialog.removeEventListener("keydown", handleKeyDown); }, [open, close]); useEffect(() => { diff --git a/fluxapay_frontend/src/components/OfflineBanner.tsx b/fluxapay_frontend/src/components/OfflineBanner.tsx index dc347e05..413374a3 100644 --- a/fluxapay_frontend/src/components/OfflineBanner.tsx +++ b/fluxapay_frontend/src/components/OfflineBanner.tsx @@ -1,7 +1,7 @@ "use client"; import { useOnlineStatus } from "@/hooks/useOnlineStatus"; -import { AlertCircle, Wifi } from "lucide-react"; +import { AlertCircle } from "lucide-react"; export function OfflineBanner() { const isOnline = useOnlineStatus(); diff --git a/fluxapay_frontend/src/components/checkout/CheckoutWidget.tsx b/fluxapay_frontend/src/components/checkout/CheckoutWidget.tsx index 7618569f..ac55ea28 100644 --- a/fluxapay_frontend/src/components/checkout/CheckoutWidget.tsx +++ b/fluxapay_frontend/src/components/checkout/CheckoutWidget.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import Image from "next/image"; import { useTranslations } from "next-intl"; import { X } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; @@ -145,7 +146,7 @@ export function CheckoutWidget({
{customization?.logoUrl && ( - + )}
{merchantName && ( diff --git a/fluxapay_frontend/src/components/checkout/__tests__/PaymentQRCode.test.tsx b/fluxapay_frontend/src/components/checkout/__tests__/PaymentQRCode.test.tsx index dcccf5e7..1000e25c 100644 --- a/fluxapay_frontend/src/components/checkout/__tests__/PaymentQRCode.test.tsx +++ b/fluxapay_frontend/src/components/checkout/__tests__/PaymentQRCode.test.tsx @@ -33,4 +33,22 @@ describe('PaymentQRCode', () => { screen.getByRole('button', { name: 'Copy deposit address' }), ).toBeInTheDocument(); }); + + it('re-renders QR code when depositAddress prop changes', () => { + const { rerender } = render(); + + const canvas1 = screen.getByTestId('qr-canvas'); + // The value prop on the canvas encodes the Stellar URI — capture it + const initialValue = canvas1.getAttribute('value'); + + const newAddress = 'GNEWADDRESS99999STELLARADDR'; + rerender(); + + const canvas2 = screen.getByTestId('qr-canvas'); + const updatedValue = canvas2.getAttribute('value'); + + // The QR value must have changed to reflect the new deposit address + expect(updatedValue).not.toBe(initialValue); + expect(updatedValue).toContain(newAddress); + }); }); diff --git a/fluxapay_frontend/src/components/docs/EndpointCard.tsx b/fluxapay_frontend/src/components/docs/EndpointCard.tsx index 920b2d7a..4c110aad 100644 --- a/fluxapay_frontend/src/components/docs/EndpointCard.tsx +++ b/fluxapay_frontend/src/components/docs/EndpointCard.tsx @@ -83,7 +83,7 @@ export function EndpointCard({ if (param.type === "number" || param.type === "integer") body[param.name] = Number(val); else if (param.type === "boolean") body[param.name] = val === "true"; else if (param.type === "object" || param.type === "array") { - try { body[param.name] = JSON.parse(val); } catch (err) { body[param.name] = val; } + try { body[param.name] = JSON.parse(val); } catch { body[param.name] = val; } } else body[param.name] = val; } diff --git a/fluxapay_frontend/src/components/pricing/ComparisonTable.tsx b/fluxapay_frontend/src/components/pricing/ComparisonTable.tsx index 4df4d939..0a1091bd 100644 --- a/fluxapay_frontend/src/components/pricing/ComparisonTable.tsx +++ b/fluxapay_frontend/src/components/pricing/ComparisonTable.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { CheckCircle2, Circle } from "lucide-react"; export interface ComparisonFeature { @@ -37,8 +38,8 @@ export default function ComparisonTable({ features }: ComparisonTableProps) { - {features.map((featureGroup, groupIndex) => ( - + {features.map((featureGroup) => ( + {featureGroup.category} @@ -77,7 +78,7 @@ export default function ComparisonTable({ features }: ComparisonTableProps) { ))} - + ))} diff --git a/fluxapay_frontend/src/features/dashboard/components/overview/FxRateBadge.tsx b/fluxapay_frontend/src/features/dashboard/components/overview/FxRateBadge.tsx index 97e5ad67..ca9f455e 100644 --- a/fluxapay_frontend/src/features/dashboard/components/overview/FxRateBadge.tsx +++ b/fluxapay_frontend/src/features/dashboard/components/overview/FxRateBadge.tsx @@ -1,8 +1,7 @@ "use client"; import { useFxRate } from "@/hooks/useFxRate"; -import { Coins, Loader2, AlertCircle } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { Coins, AlertCircle } from "lucide-react"; export function FxRateBadge({ currency }: { currency?: string }) { // If no currency is passed, we could read it from the user context. For now, default to NGN or USD. diff --git a/fluxapay_frontend/src/features/settings/components/SettingsPage.tsx b/fluxapay_frontend/src/features/settings/components/SettingsPage.tsx index 50ac75b7..10198649 100644 --- a/fluxapay_frontend/src/features/settings/components/SettingsPage.tsx +++ b/fluxapay_frontend/src/features/settings/components/SettingsPage.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import Input from "@/components/Input"; import { Button } from "@/components/Button"; import { Modal } from "@/components/Modal"; -import { api, ApiError, clearToken } from "@/lib/api"; +import { api, ApiError } from "@/lib/api"; import { logout, getToken } from "@/lib/auth"; import { DOCS_URLS } from "@/lib/docs"; import { isValidHttpsWebhookUrl } from "@/lib/webhookUrl"; diff --git a/fluxapay_frontend/src/hooks/useCacheTimestamp.ts b/fluxapay_frontend/src/hooks/useCacheTimestamp.ts index a572be5b..90bc51cc 100644 --- a/fluxapay_frontend/src/hooks/useCacheTimestamp.ts +++ b/fluxapay_frontend/src/hooks/useCacheTimestamp.ts @@ -19,7 +19,7 @@ export function useCacheTimestamp(key: string) { setTimestamp(ts); updateMinutesAgo(ts); } - }, [key]); // eslint-disable-line react-hooks/exhaustive-deps + }, [key]); useEffect(() => { if (!timestamp) return; @@ -29,7 +29,7 @@ export function useCacheTimestamp(key: string) { }, 10000); return () => clearInterval(interval); - }, [timestamp]); // eslint-disable-line react-hooks/exhaustive-deps + }, [timestamp]); function recordTimestamp() { const now = Date.now(); diff --git a/fluxapay_frontend/src/hooks/usePaymentStatus.ts b/fluxapay_frontend/src/hooks/usePaymentStatus.ts index 796d4bc4..4493f687 100644 --- a/fluxapay_frontend/src/hooks/usePaymentStatus.ts +++ b/fluxapay_frontend/src/hooks/usePaymentStatus.ts @@ -81,6 +81,8 @@ interface UsePaymentStatusReturn { isOffline: boolean; retryConnection: () => Promise; serverTimeOffset: number; + /** True for one render cycle when the deposit address has just changed */ + depositAddressUpdated: boolean; } /** @@ -95,6 +97,7 @@ export function usePaymentStatus(paymentId: string): UsePaymentStatusReturn { const [connectionType, setConnectionType] = useState(null); const [isOffline, setIsOffline] = useState(false); const [serverTimeOffset, setServerTimeOffset] = useState(0); + const [depositAddressUpdated, setDepositAddressUpdated] = useState(false); // Use refs to track mutable state without triggering re-renders or lint issues const eventSourceRef = useRef(null); @@ -195,8 +198,21 @@ export function usePaymentStatus(paymentId: string): UsePaymentStatusReturn { if (!prev) return prev; const statusChanged = prev.status !== data.status; const paidAmountChanged = data.paidAmount !== undefined && prev.paidAmount !== data.paidAmount; - if (statusChanged || paidAmountChanged) { - return { ...prev, status: data.status, ...(data.paidAmount !== undefined ? { paidAmount: data.paidAmount } : {}) }; + const addressChanged = + data.address !== undefined && + typeof data.address === 'string' && + data.address !== '' && + prev.address !== data.address; + if (statusChanged || paidAmountChanged || addressChanged) { + if (addressChanged) { + setDepositAddressUpdated(true); + } + return { + ...prev, + status: data.status, + ...(data.paidAmount !== undefined ? { paidAmount: data.paidAmount } : {}), + ...(addressChanged ? { address: data.address as string } : {}), + }; } return prev; }); @@ -269,8 +285,21 @@ export function usePaymentStatus(paymentId: string): UsePaymentStatusReturn { if (!prev) return prev; const statusChanged = prev.status !== data.status; const paidAmountChanged = data.paidAmount !== undefined && prev.paidAmount !== data.paidAmount; - if (statusChanged || paidAmountChanged) { - return { ...prev, status: data.status, ...(data.paidAmount !== undefined ? { paidAmount: data.paidAmount } : {}) }; + const addressChanged = + data.address !== undefined && + typeof data.address === 'string' && + data.address !== '' && + prev.address !== data.address; + if (statusChanged || paidAmountChanged || addressChanged) { + if (addressChanged) { + setDepositAddressUpdated(true); + } + return { + ...prev, + status: data.status, + ...(data.paidAmount !== undefined ? { paidAmount: data.paidAmount } : {}), + ...(addressChanged ? { address: data.address as string } : {}), + }; } return prev; }); @@ -321,5 +350,13 @@ export function usePaymentStatus(paymentId: string): UsePaymentStatusReturn { await fetchPayment(); }, [fetchPayment]); - return { payment, loading, error, connectionType, isOffline, retryConnection, serverTimeOffset }; + // Auto-clear the depositAddressUpdated flag after one tick so consumers + // can use it as a one-shot signal without managing their own reset. + useEffect(() => { + if (!depositAddressUpdated) return; + const id = setTimeout(() => setDepositAddressUpdated(false), 0); + return () => clearTimeout(id); + }, [depositAddressUpdated]); + + return { payment, loading, error, connectionType, isOffline, retryConnection, serverTimeOffset, depositAddressUpdated }; } diff --git a/fluxapay_frontend/src/lib/__tests__/stellar.test.ts b/fluxapay_frontend/src/lib/__tests__/stellar.test.ts index 057ad662..947c9b48 100644 --- a/fluxapay_frontend/src/lib/__tests__/stellar.test.ts +++ b/fluxapay_frontend/src/lib/__tests__/stellar.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { getStellarNetwork, getStellarExpertTxUrl, diff --git a/fluxapay_frontend/src/lib/docs.ts b/fluxapay_frontend/src/lib/docs.ts index c6eb2a60..e3a0a1c3 100644 --- a/fluxapay_frontend/src/lib/docs.ts +++ b/fluxapay_frontend/src/lib/docs.ts @@ -2,9 +2,6 @@ // STATUS_URL can be overridden via NEXT_PUBLIC_STATUS_URL env var (e.g. https://status.fluxapay.com) const STATUS_URL = process.env.NEXT_PUBLIC_STATUS_URL ?? "/status"; -// EXTERNAL_DOCS_URL can be overridden via NEXT_PUBLIC_EXTERNAL_DOCS_URL env var (e.g. https://docs.fluxapay.com) -const EXTERNAL_DOCS_URL = process.env.NEXT_PUBLIC_EXTERNAL_DOCS_URL; - export const DOCS_URLS = { API_REFERENCE: "/docs/api-reference", GETTING_STARTED: "/docs/getting-started", diff --git a/fluxapay_frontend/src/types/payment.ts b/fluxapay_frontend/src/types/payment.ts index 5addc3e9..f28911a9 100644 --- a/fluxapay_frontend/src/types/payment.ts +++ b/fluxapay_frontend/src/types/payment.ts @@ -1,6 +1,7 @@ /** * Payment status enum values — aligned with backend Prisma PaymentStatus enum. - * Values: pending, partially_paid, confirmed, overpaid, expired, failed, paid, completed + * Values: pending, partially_paid, confirmed, overpaid, expired, failed, + * paid, completed, cancelled, refunded, partially_refunded */ export type PaymentStatus = | 'pending' @@ -10,7 +11,10 @@ export type PaymentStatus = | 'expired' | 'failed' | 'paid' - | 'completed'; + | 'completed' + | 'cancelled' + | 'refunded' + | 'partially_refunded'; export interface Payment { id: string; diff --git a/package.json b/package.json index 8c311ec2..74111cfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test:ci": "echo 'Running integration tests...' && .github/workflows/integration-tests.yml", + "test:ci": "npm run test:backend && npm run test:frontend", "test:backend": "cd fluxapay_backend && npm test", "test:frontend": "cd fluxapay_frontend && npm test", "test:e2e": "cd fluxapay_frontend && npm run test:e2e",